ranni-mcp 0.1.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 +163 -0
- package/bin/ranni-mcp.js +2 -0
- package/package.json +30 -0
- package/src/config.ts +59 -0
- package/src/conflict.ts +74 -0
- package/src/index.ts +276 -0
- package/src/init.ts +57 -0
- package/src/queue.ts +139 -0
- package/src/runs.ts +25 -0
- package/src/types.ts +38 -0
- package/src/worker.ts +119 -0
- package/templates/.agents.yaml +17 -0
- package/templates/ranni/SKILL.md +206 -0
package/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# ranni
|
|
2
|
+
|
|
3
|
+
Provider-agnostic multi-agent orchestration MCP server. Lets a manager agent (Claude Code, Codex, Copilot) dispatch autonomous worker subprocesses, track their progress via a file-backed task queue, and receive results when workers finish, need help, or error.
|
|
4
|
+
|
|
5
|
+
The agent you are already talking to is the UI. Ranni is the plumbing.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
You ↔ Manager agent (Claude Code, your existing terminal agent)
|
|
13
|
+
│
|
|
14
|
+
│ MCP tools
|
|
15
|
+
▼
|
|
16
|
+
┌─────────────────────┐
|
|
17
|
+
│ ranni │
|
|
18
|
+
│ (MCP server) │
|
|
19
|
+
└──────────┬──────────┘
|
|
20
|
+
│ spawns subprocesses
|
|
21
|
+
┌──────────┼──────────┐
|
|
22
|
+
▼ ▼ ▼
|
|
23
|
+
[Worker A] [Worker B] [Worker C] ← up to max_workers
|
|
24
|
+
│ │ │
|
|
25
|
+
└──────────┴──────────┘
|
|
26
|
+
│ structured JSON result
|
|
27
|
+
▼
|
|
28
|
+
Manager reads callbacks, decides what to do next
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Workers run fully autonomously. They never talk to the user directly — they exit with a structured result, ranni queues it, and the manager reads it and decides whether to escalate.
|
|
32
|
+
|
|
33
|
+
The task queue is a plain JSON file on disk. It survives reboots, is manually editable, and is the single source of truth for all task state.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
bun add ranni-mcp
|
|
41
|
+
bun node_modules/ranni-mcp/src/init.ts
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`init` writes three things into your project:
|
|
45
|
+
|
|
46
|
+
| File | Behaviour |
|
|
47
|
+
|------|-----------|
|
|
48
|
+
| `.claude/skills/ranni/` | Manager skill for Claude Code — always overwrites |
|
|
49
|
+
| `.agents.yaml` | Starter config — skipped if already exists |
|
|
50
|
+
| `.mcp.json` | Merges the ranni entry, preserving all existing entries |
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
Edit `.agents.yaml` at your repo root:
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
worker:
|
|
60
|
+
command: claude
|
|
61
|
+
args: [--print, --dangerously-skip-permissions]
|
|
62
|
+
|
|
63
|
+
max_workers: 3
|
|
64
|
+
persist_runs: false # set true to archive full worker stdout
|
|
65
|
+
|
|
66
|
+
dirs:
|
|
67
|
+
root: .
|
|
68
|
+
backend: ./backend
|
|
69
|
+
web: ./apps/web
|
|
70
|
+
mobile: ./apps/mobile
|
|
71
|
+
|
|
72
|
+
# Optional — injected into every MCP tool response as a reminder
|
|
73
|
+
# manager_context: |
|
|
74
|
+
# Always call get_pending_results() before dispatching new tasks.
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`worker.command` can be any CLI that accepts a prompt on stdin — `claude`, `codex`, `aider`, anything.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## MCP tools
|
|
82
|
+
|
|
83
|
+
| Tool | Description |
|
|
84
|
+
|------|-------------|
|
|
85
|
+
| `dispatch_task` | Push one or more tasks onto the queue |
|
|
86
|
+
| `list_active_workers` | Snapshot of running + queued tasks |
|
|
87
|
+
| `get_pending_results` | Read completed results (drains buffer by default) |
|
|
88
|
+
| `cancel_task` | Cancel a pending task by ID |
|
|
89
|
+
|
|
90
|
+
### `dispatch_task` input
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
{
|
|
94
|
+
tasks: Array<{
|
|
95
|
+
id: string // unique ID you choose for tracking
|
|
96
|
+
dir: string // key from .agents.yaml dirs
|
|
97
|
+
task: string // full self-contained task description
|
|
98
|
+
context?: string // optional background, constraints
|
|
99
|
+
links?: string[] // URLs the worker should read first (tickets, PRs, docs)
|
|
100
|
+
relevant_files?: string[] // files already identified — worker starts here
|
|
101
|
+
depends_on?: string[] // task IDs that must be "done" before this starts
|
|
102
|
+
}>
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Worker output protocol
|
|
109
|
+
|
|
110
|
+
Every worker must end its output with this block as the very last thing written:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
<orchestrator_result>
|
|
114
|
+
{"status":"done","summary":"one-line summary","files_changed":["relative/path"],"message":"optional"}
|
|
115
|
+
</orchestrator_result>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Valid statuses: `done`, `needs_help`, `error`.
|
|
119
|
+
|
|
120
|
+
If the marker is absent (crash, unexpected exit), ranni synthesises an `error` result from the last 2000 chars of stdout.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Task statuses
|
|
125
|
+
|
|
126
|
+
| Status | Meaning |
|
|
127
|
+
|--------|---------|
|
|
128
|
+
| `pending` | Queued, not yet started |
|
|
129
|
+
| `running` | Worker subprocess active |
|
|
130
|
+
| `done` | Completed successfully |
|
|
131
|
+
| `done_with_conflict` | Done, but touched a file another worker already modified — resolution task auto-dispatched |
|
|
132
|
+
| `needs_help` | Worker could not proceed — manager must act |
|
|
133
|
+
| `error` | Worker failed — manager may retry or cancel |
|
|
134
|
+
| `cancelled` | Removed before it ran |
|
|
135
|
+
|
|
136
|
+
Tasks left in `running` state from a crashed session are automatically reset to `pending` on startup.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Task dependencies
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"id": "web-ui",
|
|
145
|
+
"depends_on": ["api-endpoint", "mobile-service"]
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Ranni skips a task until every ID in `depends_on` has status `done`. IDs not found in the queue are treated as satisfied (completed in a prior session).
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Runtime files
|
|
154
|
+
|
|
155
|
+
- `.agent-queue.json` — task queue state, lives alongside `.agents.yaml` (gitignore this)
|
|
156
|
+
- `tools/ranni/runs/<timestamp>/` — worker output archives when `persist_runs: true` (gitignore this)
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Requirements
|
|
161
|
+
|
|
162
|
+
- [Bun](https://bun.sh) ≥ 1.0
|
|
163
|
+
- A Claude Code (or compatible) setup with MCP support
|
package/bin/ranni-mcp.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ranni-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Provider-agnostic multi-agent orchestration MCP server",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"bin",
|
|
8
|
+
"src",
|
|
9
|
+
"templates"
|
|
10
|
+
],
|
|
11
|
+
"bin": {
|
|
12
|
+
"ranni-mcp": "./bin/ranni-mcp.js"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "bun run src/index.ts",
|
|
16
|
+
"init": "bun run src/init.ts",
|
|
17
|
+
"build": "bun build --compile src/index.ts --outfile dist/ranni-mcp",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"publish": "bun run scripts/publish.ts"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
23
|
+
"yaml": "^2.4.0",
|
|
24
|
+
"zod": "^3.22.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/bun": "latest",
|
|
28
|
+
"typescript": "^5.4.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { Config } from './types.js';
|
|
6
|
+
|
|
7
|
+
const ConfigSchema = z.object({
|
|
8
|
+
worker: z.object({
|
|
9
|
+
command: z.string(),
|
|
10
|
+
args: z.array(z.string()).default([])
|
|
11
|
+
}),
|
|
12
|
+
max_workers: z.number().int().min(1).default(3),
|
|
13
|
+
persist_runs: z.boolean().default(false),
|
|
14
|
+
dirs: z.record(z.string()),
|
|
15
|
+
manager_context: z.string().optional()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function findConfigDir(startDir: string): string | null {
|
|
19
|
+
let dir = startDir
|
|
20
|
+
while (true) {
|
|
21
|
+
if (existsSync(join(dir, '.agents.yaml'))) return dir
|
|
22
|
+
if (existsSync(join(dir, '.git'))) return null
|
|
23
|
+
const parent = dirname(dir)
|
|
24
|
+
if (parent === dir) return null
|
|
25
|
+
dir = parent
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadConfig(startDir = process.cwd()): { config: Config; configDir: string } {
|
|
30
|
+
const configDir = findConfigDir(startDir)
|
|
31
|
+
if (!configDir) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`No .agents.yaml found. Walk from "${startDir}" to git root found nothing.\n` +
|
|
34
|
+
`Create .agents.yaml at your repo root. Run: bun node_modules/ranni-mcp/src/init.ts`
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const raw = readFileSync(join(configDir, '.agents.yaml'), 'utf8')
|
|
39
|
+
const parsed = parse(raw)
|
|
40
|
+
const result = ConfigSchema.safeParse(parsed)
|
|
41
|
+
|
|
42
|
+
if (!result.success) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`.agents.yaml is invalid:\n${result.error.issues.map(i => ` ${i.path.join('.')}: ${i.message}`).join('\n')}`
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const config = result.data as Config
|
|
49
|
+
|
|
50
|
+
for (const [key, rel] of Object.entries(config.dirs)) {
|
|
51
|
+
const abs = join(configDir, rel)
|
|
52
|
+
if (!existsSync(abs)) {
|
|
53
|
+
throw new Error(`.agents.yaml dirs.${key} = "${rel}" does not exist (resolved to "${abs}")`)
|
|
54
|
+
}
|
|
55
|
+
config.dirs[key] = abs
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { config, configDir }
|
|
59
|
+
}
|
package/src/conflict.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { enqueue, readQueue, writeQueue } from './queue.js';
|
|
2
|
+
import type { QueueFile, Task, WorkerResult } from './types.js';
|
|
3
|
+
|
|
4
|
+
export type ConflictInfo = {
|
|
5
|
+
taskId: string
|
|
6
|
+
conflictingFiles: Array<{ file: string; previousTaskId: string }>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function detectConflict(result: WorkerResult, queue: QueueFile, taskId: string): ConflictInfo | null {
|
|
10
|
+
const conflicts: ConflictInfo['conflictingFiles'] = []
|
|
11
|
+
|
|
12
|
+
for (const file of result.files_changed) {
|
|
13
|
+
const prev = queue.touched_files[file]
|
|
14
|
+
if (prev && prev !== taskId) {
|
|
15
|
+
conflicts.push({ file, previousTaskId: prev })
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return conflicts.length > 0 ? { taskId, conflictingFiles: conflicts } : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function getDiff(file: string, cwd: string): Promise<string> {
|
|
23
|
+
try {
|
|
24
|
+
const proc = Bun.spawn(['git', 'diff', 'HEAD', '--', file], {
|
|
25
|
+
cwd,
|
|
26
|
+
stdout: 'pipe',
|
|
27
|
+
stderr: 'pipe'
|
|
28
|
+
})
|
|
29
|
+
const output = await new Response(proc.stdout).text()
|
|
30
|
+
await proc.exited
|
|
31
|
+
return output || '(no diff available)'
|
|
32
|
+
} catch {
|
|
33
|
+
return '(could not generate diff)'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function handleConflict(
|
|
38
|
+
queueFilePath: string,
|
|
39
|
+
originalTask: Task,
|
|
40
|
+
conflict: ConflictInfo,
|
|
41
|
+
resolvedDir: string
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const queue = readQueue(queueFilePath)
|
|
44
|
+
const original = queue.tasks.find(t => t.id === originalTask.id)
|
|
45
|
+
if (original) original.status = 'done_with_conflict'
|
|
46
|
+
|
|
47
|
+
const existingResolutions = queue.tasks.filter(t => t.id.startsWith(`${originalTask.id}-conflict-`)).length
|
|
48
|
+
|
|
49
|
+
const resolutionId = `${originalTask.id}-conflict-${existingResolutions + 1}`
|
|
50
|
+
|
|
51
|
+
const diffSections: string[] = []
|
|
52
|
+
for (const { file, previousTaskId } of conflict.conflictingFiles) {
|
|
53
|
+
const diff = await getDiff(file, resolvedDir)
|
|
54
|
+
diffSections.push(`File: ${file}\nFirst modified by task: ${previousTaskId}\n\nRejected diff:\n${diff}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const resolutionTask = `Resolve file conflict from task "${originalTask.id}".
|
|
58
|
+
|
|
59
|
+
The following files were already modified by an earlier worker. The first worker's version is on disk. Review the rejected diff below and apply any changes that are safe to merge without breaking the existing work.
|
|
60
|
+
|
|
61
|
+
${diffSections.join('\n\n---\n\n')}
|
|
62
|
+
|
|
63
|
+
If the changes cannot be safely merged, set status to "needs_help" and explain what a human needs to decide.`
|
|
64
|
+
|
|
65
|
+
writeQueue(queueFilePath, queue)
|
|
66
|
+
|
|
67
|
+
enqueue(queueFilePath, [
|
|
68
|
+
{
|
|
69
|
+
id: resolutionId,
|
|
70
|
+
dir: originalTask.dir,
|
|
71
|
+
task: resolutionTask
|
|
72
|
+
}
|
|
73
|
+
])
|
|
74
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { detectConflict, handleConflict } from './conflict.js';
|
|
6
|
+
import {
|
|
7
|
+
cancelTask,
|
|
8
|
+
complete,
|
|
9
|
+
enqueue,
|
|
10
|
+
getPendingResults,
|
|
11
|
+
getSnapshot,
|
|
12
|
+
queuePath,
|
|
13
|
+
readQueue,
|
|
14
|
+
resetInterrupted,
|
|
15
|
+
startNext
|
|
16
|
+
} from './queue.js';
|
|
17
|
+
import { initRun } from './runs.js';
|
|
18
|
+
import type { Config, Task } from './types.js';
|
|
19
|
+
import { runWorker } from './worker.js';
|
|
20
|
+
|
|
21
|
+
const DEFAULT_MANAGER_CONTEXT = `ORCHESTRATOR RULES (read before every action):
|
|
22
|
+
1. Always call get_pending_results before dispatching new tasks — read every result first.
|
|
23
|
+
2. Each task must be self-contained: include all file paths, context, and background the worker needs.
|
|
24
|
+
3. Use depends_on when task B genuinely needs task A's output to be on disk first.
|
|
25
|
+
4. When a worker returns needs_help, surface the question to the user before dispatching a follow-up.
|
|
26
|
+
5. When a worker returns error, decide: retry, dispatch a corrected version, or inform the user.`
|
|
27
|
+
|
|
28
|
+
function formatFooter(managerContext: string): string {
|
|
29
|
+
return `\n\n---\n${managerContext}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
const { config, configDir } = loadConfig()
|
|
34
|
+
const qPath = queuePath(configDir)
|
|
35
|
+
const runLogger = initRun(config, configDir)
|
|
36
|
+
|
|
37
|
+
resetInterrupted(qPath)
|
|
38
|
+
|
|
39
|
+
let activeCount = 0
|
|
40
|
+
|
|
41
|
+
async function tickPool() {
|
|
42
|
+
const available = config.max_workers - activeCount
|
|
43
|
+
if (available <= 0) return
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < available; i++) {
|
|
46
|
+
const task = startNext(qPath)
|
|
47
|
+
if (!task) break
|
|
48
|
+
|
|
49
|
+
activeCount++
|
|
50
|
+
runWorkerAsync(task, config, qPath, runLogger, configDir).finally(() => {
|
|
51
|
+
activeCount--
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setInterval(tickPool, 200)
|
|
57
|
+
|
|
58
|
+
const server = new Server({ name: 'ranni-mcp', version: '0.1.0' }, { capabilities: { tools: {} } })
|
|
59
|
+
|
|
60
|
+
const footer = formatFooter(config.manager_context ?? DEFAULT_MANAGER_CONTEXT)
|
|
61
|
+
|
|
62
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
63
|
+
tools: [
|
|
64
|
+
{
|
|
65
|
+
name: 'dispatch_task',
|
|
66
|
+
description:
|
|
67
|
+
'Add one or more tasks to the worker queue. Workers run autonomously up to max_workers in parallel.',
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
tasks: {
|
|
72
|
+
type: 'array',
|
|
73
|
+
items: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: {
|
|
76
|
+
id: { type: 'string', description: 'Unique ID you choose for tracking' },
|
|
77
|
+
dir: { type: 'string', description: `One of: ${Object.keys(config.dirs).join(', ')}` },
|
|
78
|
+
task: { type: 'string', description: 'Full self-contained task description for the worker' },
|
|
79
|
+
context: { type: 'string', description: 'Optional extra context: file paths, background, etc.' },
|
|
80
|
+
links: {
|
|
81
|
+
type: 'array',
|
|
82
|
+
items: { type: 'string' },
|
|
83
|
+
description:
|
|
84
|
+
'URLs the worker should read first (Notion tickets, PRs, docs). Worker is instructed to fetch these before touching any code.'
|
|
85
|
+
},
|
|
86
|
+
relevant_files: {
|
|
87
|
+
type: 'array',
|
|
88
|
+
items: { type: 'string' },
|
|
89
|
+
description:
|
|
90
|
+
'File paths already identified by the manager. Worker starts investigation here instead of searching from scratch.'
|
|
91
|
+
},
|
|
92
|
+
depends_on: {
|
|
93
|
+
type: 'array',
|
|
94
|
+
items: { type: 'string' },
|
|
95
|
+
description: 'Task IDs that must be done before this starts'
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
required: ['id', 'dir', 'task']
|
|
99
|
+
},
|
|
100
|
+
minItems: 1
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
required: ['tasks']
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'list_active_workers',
|
|
108
|
+
description: 'Snapshot of the current queue: running workers, pending tasks, and free slots.',
|
|
109
|
+
inputSchema: { type: 'object', properties: {} }
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'get_pending_results',
|
|
113
|
+
description: 'Read completed worker results since the last call. Call this before dispatching new tasks.',
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
drain: { type: 'boolean', description: 'Mark results as acknowledged after reading (default: true)' }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'cancel_task',
|
|
123
|
+
description: 'Cancel a pending (not yet running) task.',
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
properties: {
|
|
127
|
+
id: { type: 'string', description: 'Task ID to cancel' }
|
|
128
|
+
},
|
|
129
|
+
required: ['id']
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
}))
|
|
134
|
+
|
|
135
|
+
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
136
|
+
const { name, arguments: args } = request.params
|
|
137
|
+
|
|
138
|
+
switch (name) {
|
|
139
|
+
case 'dispatch_task': {
|
|
140
|
+
const tasks = (args as any).tasks as Array<{
|
|
141
|
+
id: string
|
|
142
|
+
dir: string
|
|
143
|
+
task: string
|
|
144
|
+
context?: string
|
|
145
|
+
links?: string[]
|
|
146
|
+
relevant_files?: string[]
|
|
147
|
+
depends_on?: string[]
|
|
148
|
+
}>
|
|
149
|
+
|
|
150
|
+
const unknownDirs = tasks.filter(t => !config.dirs[t.dir]).map(t => t.dir)
|
|
151
|
+
if (unknownDirs.length > 0) {
|
|
152
|
+
return {
|
|
153
|
+
content: [
|
|
154
|
+
{
|
|
155
|
+
type: 'text',
|
|
156
|
+
text: `Error: unknown dir(s): ${[...new Set(unknownDirs)].join(', ')}. Valid dirs: ${Object.keys(config.dirs).join(', ')}${footer}`
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
enqueue(qPath, tasks)
|
|
163
|
+
tickPool()
|
|
164
|
+
|
|
165
|
+
const snapshot = getSnapshot(qPath)
|
|
166
|
+
return {
|
|
167
|
+
content: [
|
|
168
|
+
{
|
|
169
|
+
type: 'text',
|
|
170
|
+
text: `Queued ${tasks.length} task(s). Active workers: ${activeCount}/${config.max_workers}. Queue depth: ${snapshot.queued.length}.${footer}`
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
case 'list_active_workers': {
|
|
177
|
+
const snapshot = getSnapshot(qPath)
|
|
178
|
+
const runningList = snapshot.running
|
|
179
|
+
.map(t => ` [running] ${t.id} (${t.dir}) — started ${t.started_at}`)
|
|
180
|
+
.join('\n')
|
|
181
|
+
const queuedList = snapshot.queued
|
|
182
|
+
.map(
|
|
183
|
+
t =>
|
|
184
|
+
` [queued] ${t.id} (${t.dir})${t.depends_on?.length ? ` — waiting on: ${t.depends_on.join(', ')}` : ''}`
|
|
185
|
+
)
|
|
186
|
+
.join('\n')
|
|
187
|
+
const lines = [
|
|
188
|
+
`Workers: ${activeCount}/${config.max_workers} active`,
|
|
189
|
+
runningList || ' (none running)',
|
|
190
|
+
queuedList || ' (queue empty)'
|
|
191
|
+
].join('\n')
|
|
192
|
+
return { content: [{ type: 'text', text: lines + footer }] }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case 'get_pending_results': {
|
|
196
|
+
const drain = (args as any)?.drain !== false
|
|
197
|
+
const results = getPendingResults(qPath, drain)
|
|
198
|
+
|
|
199
|
+
if (results.length === 0) {
|
|
200
|
+
return { content: [{ type: 'text', text: `No new results.${footer}` }] }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const formatted = results
|
|
204
|
+
.map(t => {
|
|
205
|
+
const r = t.result
|
|
206
|
+
const status = r ? r.status : t.status
|
|
207
|
+
const summary = r ? r.summary : '(no summary)'
|
|
208
|
+
const files = r?.files_changed?.length ? `\n Files: ${r.files_changed.join(', ')}` : ''
|
|
209
|
+
const msg = r?.message ? `\n Message: ${r.message}` : ''
|
|
210
|
+
return `[${status.toUpperCase()}] ${t.id} (${t.dir})\n ${summary}${files}${msg}`
|
|
211
|
+
})
|
|
212
|
+
.join('\n\n')
|
|
213
|
+
|
|
214
|
+
return { content: [{ type: 'text', text: formatted + footer }] }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case 'cancel_task': {
|
|
218
|
+
const id = (args as any).id as string
|
|
219
|
+
const cancelled = cancelTask(qPath, id)
|
|
220
|
+
const msg = cancelled
|
|
221
|
+
? `Task "${id}" cancelled.`
|
|
222
|
+
: `Task "${id}" could not be cancelled — it may be running or already finished.`
|
|
223
|
+
return { content: [{ type: 'text', text: msg + footer }] }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
default:
|
|
227
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] }
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const transport = new StdioServerTransport()
|
|
232
|
+
await server.connect(transport)
|
|
233
|
+
process.stderr.write('Orchestrator MCP server running\n')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function runWorkerAsync(
|
|
237
|
+
task: Task,
|
|
238
|
+
config: Config,
|
|
239
|
+
qPath: string,
|
|
240
|
+
runLogger: ReturnType<typeof initRun>,
|
|
241
|
+
configDir: string
|
|
242
|
+
) {
|
|
243
|
+
try {
|
|
244
|
+
const result = await runWorker(task, config)
|
|
245
|
+
|
|
246
|
+
complete(qPath, task.id, result)
|
|
247
|
+
|
|
248
|
+
runLogger?.updateSummary(readQueue(qPath).tasks)
|
|
249
|
+
|
|
250
|
+
const queue = readQueue(qPath)
|
|
251
|
+
const conflict = detectConflict(result, queue, task.id)
|
|
252
|
+
if (conflict) {
|
|
253
|
+
const resolvedDir = config.dirs[task.dir]!
|
|
254
|
+
await handleConflict(qPath, task, conflict, resolvedDir)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (runLogger) {
|
|
258
|
+
const stdout = result.message ?? result.summary
|
|
259
|
+
runLogger.logTask(task.id, stdout)
|
|
260
|
+
runLogger.updateSummary(readQueue(qPath).tasks)
|
|
261
|
+
}
|
|
262
|
+
} catch (err) {
|
|
263
|
+
complete(qPath, task.id, {
|
|
264
|
+
status: 'error',
|
|
265
|
+
summary: 'Worker threw an unexpected error',
|
|
266
|
+
files_changed: [],
|
|
267
|
+
message: String(err),
|
|
268
|
+
exit_code: 1
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
main().catch(err => {
|
|
274
|
+
process.stderr.write(`Fatal: ${err}\n`)
|
|
275
|
+
process.exit(1)
|
|
276
|
+
})
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
2
|
+
import { dirname, join } from 'path'
|
|
3
|
+
|
|
4
|
+
const templatesDir = join(import.meta.dir, '..', 'templates')
|
|
5
|
+
const cwd = process.cwd()
|
|
6
|
+
|
|
7
|
+
function copySkill() {
|
|
8
|
+
const src = join(templatesDir, 'ranni')
|
|
9
|
+
const dest = join(cwd, '.claude', 'skills', 'ranni')
|
|
10
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
11
|
+
cpSync(src, dest, { recursive: true })
|
|
12
|
+
console.log('✓ Skill installed at .claude/skills/ranni/')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function copyAgentsYaml() {
|
|
16
|
+
const dest = join(cwd, '.agents.yaml')
|
|
17
|
+
if (existsSync(dest)) {
|
|
18
|
+
console.log('⚠ .agents.yaml already exists — skipped (edit it to configure your dirs)')
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
cpSync(join(templatesDir, '.agents.yaml'), dest)
|
|
22
|
+
console.log('✓ .agents.yaml created — edit dirs to match your project')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mergeMcpJson() {
|
|
26
|
+
const dest = join(cwd, '.mcp.json')
|
|
27
|
+
|
|
28
|
+
let existing: Record<string, any> = {}
|
|
29
|
+
if (existsSync(dest)) {
|
|
30
|
+
try {
|
|
31
|
+
existing = JSON.parse(readFileSync(dest, 'utf8'))
|
|
32
|
+
} catch {
|
|
33
|
+
console.error('✗ .mcp.json exists but contains invalid JSON — fix it manually and re-run')
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (existing.mcpServers?.['ranni-mcp']) {
|
|
39
|
+
console.log('⚠ .mcp.json already has a "ranni-mcp" entry — skipped')
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
existing.mcpServers ??= {}
|
|
44
|
+
existing.mcpServers['ranni-mcp'] = {
|
|
45
|
+
command: 'bun',
|
|
46
|
+
args: ['run', 'node_modules/ranni-mcp/src/index.ts']
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
writeFileSync(dest, JSON.stringify(existing, null, 2) + '\n', 'utf8')
|
|
50
|
+
console.log('✓ .mcp.json updated with ranni-mcp MCP server entry')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
copySkill()
|
|
54
|
+
copyAgentsYaml()
|
|
55
|
+
mergeMcpJson()
|
|
56
|
+
|
|
57
|
+
console.log('\nDone. Edit .agents.yaml to set your project dirs, then restart Claude Code.')
|
package/src/queue.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import type { QueueFile, Task, TaskStatus, WorkerResult } from './types.js';
|
|
4
|
+
|
|
5
|
+
export function queuePath(configDir: string): string {
|
|
6
|
+
return join(configDir, '.agent-queue.json')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function readQueue(path: string): QueueFile {
|
|
10
|
+
if (!existsSync(path)) return { tasks: [], touched_files: {} }
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(path, 'utf8')) as QueueFile
|
|
13
|
+
} catch {
|
|
14
|
+
return { tasks: [], touched_files: {} }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function writeQueue(path: string, queue: QueueFile): void {
|
|
19
|
+
const tmp = path + '.tmp'
|
|
20
|
+
writeFileSync(tmp, JSON.stringify(queue, null, 2), 'utf8')
|
|
21
|
+
renameSync(tmp, path)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function enqueue(
|
|
25
|
+
path: string,
|
|
26
|
+
tasks: Omit<Task, 'status' | 'created_at' | 'started_at' | 'finished_at' | 'result' | 'acknowledged'>[]
|
|
27
|
+
): void {
|
|
28
|
+
const queue = readQueue(path)
|
|
29
|
+
const now = new Date().toISOString()
|
|
30
|
+
for (const t of tasks) {
|
|
31
|
+
queue.tasks.push({
|
|
32
|
+
...t,
|
|
33
|
+
status: 'pending',
|
|
34
|
+
created_at: now,
|
|
35
|
+
started_at: null,
|
|
36
|
+
finished_at: null,
|
|
37
|
+
result: null,
|
|
38
|
+
acknowledged: false
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
writeQueue(path, queue)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function startNext(path: string): Task | null {
|
|
45
|
+
const queue = readQueue(path)
|
|
46
|
+
const doneIds = new Set(queue.tasks.filter(t => t.status === 'done').map(t => t.id))
|
|
47
|
+
|
|
48
|
+
const idx = queue.tasks.findIndex(t => {
|
|
49
|
+
if (t.status !== 'pending') return false
|
|
50
|
+
if (!t.depends_on || t.depends_on.length === 0) return true
|
|
51
|
+
return t.depends_on.every(dep => doneIds.has(dep))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
if (idx === -1) return null
|
|
55
|
+
|
|
56
|
+
queue.tasks[idx]!.status = 'running'
|
|
57
|
+
queue.tasks[idx]!.started_at = new Date().toISOString()
|
|
58
|
+
writeQueue(path, queue)
|
|
59
|
+
return queue.tasks[idx]!
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function complete(path: string, id: string, result: WorkerResult): void {
|
|
63
|
+
const queue = readQueue(path)
|
|
64
|
+
const task = queue.tasks.find(t => t.id === id)
|
|
65
|
+
if (!task) return
|
|
66
|
+
|
|
67
|
+
const terminalStatus: TaskStatus = result.status === 'done' ? 'done' : result.status
|
|
68
|
+
task.status = terminalStatus
|
|
69
|
+
task.finished_at = new Date().toISOString()
|
|
70
|
+
task.result = result
|
|
71
|
+
|
|
72
|
+
for (const file of result.files_changed) {
|
|
73
|
+
queue.touched_files[file] = id
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
writeQueue(path, queue)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function markConflict(path: string, id: string): void {
|
|
80
|
+
const queue = readQueue(path)
|
|
81
|
+
const task = queue.tasks.find(t => t.id === id)
|
|
82
|
+
if (task) {
|
|
83
|
+
task.status = 'done_with_conflict'
|
|
84
|
+
writeQueue(path, queue)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resetInterrupted(path: string): void {
|
|
89
|
+
const queue = readQueue(path)
|
|
90
|
+
let changed = false
|
|
91
|
+
for (const task of queue.tasks) {
|
|
92
|
+
if (task.status === 'running') {
|
|
93
|
+
task.status = 'pending'
|
|
94
|
+
task.started_at = null
|
|
95
|
+
changed = true
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (changed) writeQueue(path, queue)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function cancelTask(path: string, id: string): boolean {
|
|
102
|
+
const queue = readQueue(path)
|
|
103
|
+
const task = queue.tasks.find(t => t.id === id)
|
|
104
|
+
if (!task || task.status !== 'pending') return false
|
|
105
|
+
task.status = 'cancelled'
|
|
106
|
+
writeQueue(path, queue)
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function getPendingResults(path: string, drain: boolean): Task[] {
|
|
111
|
+
const queue = readQueue(path)
|
|
112
|
+
const terminal: TaskStatus[] = ['done', 'done_with_conflict', 'needs_help', 'error']
|
|
113
|
+
const results = queue.tasks.filter(t => terminal.includes(t.status) && !t.acknowledged)
|
|
114
|
+
|
|
115
|
+
if (drain && results.length > 0) {
|
|
116
|
+
const ids = new Set(results.map(t => t.id))
|
|
117
|
+
for (const task of queue.tasks) {
|
|
118
|
+
if (ids.has(task.id)) task.acknowledged = true
|
|
119
|
+
}
|
|
120
|
+
writeQueue(path, queue)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return results
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getSnapshot(path: string): {
|
|
127
|
+
running: Task[]
|
|
128
|
+
queued: Task[]
|
|
129
|
+
slots_free: number
|
|
130
|
+
max_workers: number
|
|
131
|
+
} {
|
|
132
|
+
const queue = readQueue(path)
|
|
133
|
+
return {
|
|
134
|
+
running: queue.tasks.filter(t => t.status === 'running'),
|
|
135
|
+
queued: queue.tasks.filter(t => t.status === 'pending'),
|
|
136
|
+
slots_free: 0,
|
|
137
|
+
max_workers: 0
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/runs.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import type { Config, Task } from './types.js';
|
|
4
|
+
|
|
5
|
+
export type RunLogger = {
|
|
6
|
+
logTask(id: string, stdout: string): void
|
|
7
|
+
updateSummary(tasks: Task[]): void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function initRun(config: Config, configDir: string): RunLogger | null {
|
|
11
|
+
if (!config.persist_runs) return null
|
|
12
|
+
|
|
13
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
|
14
|
+
const runDir = join(configDir, 'tools', 'ranni', 'runs', ts)
|
|
15
|
+
mkdirSync(runDir, { recursive: true })
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
logTask(id: string, stdout: string): void {
|
|
19
|
+
writeFileSync(join(runDir, `${id}.txt`), stdout, 'utf8')
|
|
20
|
+
},
|
|
21
|
+
updateSummary(tasks: Task[]): void {
|
|
22
|
+
writeFileSync(join(runDir, 'summary.json'), JSON.stringify(tasks, null, 2), 'utf8')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type TaskStatus = 'pending' | 'running' | 'done' | 'done_with_conflict' | 'needs_help' | 'error' | 'cancelled'
|
|
2
|
+
|
|
3
|
+
export type Task = {
|
|
4
|
+
id: string
|
|
5
|
+
status: TaskStatus
|
|
6
|
+
dir: string
|
|
7
|
+
task: string
|
|
8
|
+
context?: string
|
|
9
|
+
links?: string[]
|
|
10
|
+
relevant_files?: string[]
|
|
11
|
+
depends_on?: string[]
|
|
12
|
+
created_at: string
|
|
13
|
+
started_at: string | null
|
|
14
|
+
finished_at: string | null
|
|
15
|
+
result: WorkerResult | null
|
|
16
|
+
acknowledged: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type WorkerResult = {
|
|
20
|
+
status: 'done' | 'needs_help' | 'error'
|
|
21
|
+
summary: string
|
|
22
|
+
files_changed: string[]
|
|
23
|
+
message?: string
|
|
24
|
+
exit_code: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type QueueFile = {
|
|
28
|
+
tasks: Task[]
|
|
29
|
+
touched_files: Record<string, string>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type Config = {
|
|
33
|
+
worker: { command: string; args: string[] }
|
|
34
|
+
max_workers: number
|
|
35
|
+
persist_runs: boolean
|
|
36
|
+
dirs: Record<string, string>
|
|
37
|
+
manager_context?: string
|
|
38
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Config, Task, WorkerResult } from './types.js';
|
|
2
|
+
|
|
3
|
+
export const WORKER_SYSTEM_PROMPT = `You are an autonomous coding agent. Complete the task below fully and independently.
|
|
4
|
+
Do not ask for confirmation. Do not stop mid-task.
|
|
5
|
+
|
|
6
|
+
## Required phases — follow in order
|
|
7
|
+
|
|
8
|
+
### Phase 1: Investigate before writing any code
|
|
9
|
+
- Read every file relevant to the task. Search by component name, feature name, or symbol.
|
|
10
|
+
- Understand the full structure around the problem: layout hierarchy, data flow, navigation stack, etc.
|
|
11
|
+
- Identify the ROOT CAUSE — not a surface symptom. State your diagnosis explicitly before making changes.
|
|
12
|
+
- If you are fixing a visual/layout bug: trace the actual render tree. Know exactly which element is
|
|
13
|
+
responsible for the incorrect behavior before touching any file.
|
|
14
|
+
|
|
15
|
+
### Phase 2: Apply the minimal targeted fix
|
|
16
|
+
- Change only what is needed to address the root cause you identified.
|
|
17
|
+
- Do not add defensive code for unrelated edge cases.
|
|
18
|
+
- Do not refactor surrounding code unless it is blocking the fix.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
When you are done — whether successful, blocked, or errored — output the following
|
|
23
|
+
as the very last thing you write, with no other text after it:
|
|
24
|
+
|
|
25
|
+
<orchestrator_result>
|
|
26
|
+
{"status":"done|needs_help|error","summary":"one-line summary","files_changed":["relative/path"],"message":"optional longer message"}
|
|
27
|
+
</orchestrator_result>
|
|
28
|
+
|
|
29
|
+
Rules:
|
|
30
|
+
- Use "done" when the task is fully complete.
|
|
31
|
+
- Use "needs_help" only when you genuinely cannot proceed without information you do not have.
|
|
32
|
+
- Use "error" when you attempted the task and it failed.
|
|
33
|
+
- "files_changed" must list every file you created or modified, as paths relative to your working directory.
|
|
34
|
+
- "message" is required for "needs_help" and "error"; optional for "done".
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
`
|
|
38
|
+
|
|
39
|
+
function buildPrompt(task: Task): string {
|
|
40
|
+
let prompt = WORKER_SYSTEM_PROMPT
|
|
41
|
+
|
|
42
|
+
if (task.links?.length) {
|
|
43
|
+
prompt += `REFERENCE LINKS (read these first — they contain the original issue description, acceptance criteria, and any screenshots):\n`
|
|
44
|
+
prompt += task.links.map(l => ` - ${l}`).join('\n') + '\n\n'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (task.relevant_files?.length) {
|
|
48
|
+
prompt += `RELEVANT FILES (already identified by the manager — start your investigation here):\n`
|
|
49
|
+
prompt += task.relevant_files.map(f => ` - ${f}`).join('\n') + '\n\n'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
prompt += `TASK:\n${task.task}\n`
|
|
53
|
+
if (task.context) prompt += `\nCONTEXT:\n${task.context}\n`
|
|
54
|
+
return prompt
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseResult(stdout: string, exitCode: number): WorkerResult {
|
|
58
|
+
const match = stdout.match(/<orchestrator_result>\s*([\s\S]*?)\s*<\/orchestrator_result>/)
|
|
59
|
+
if (!match || !match[1]) {
|
|
60
|
+
return {
|
|
61
|
+
status: 'error',
|
|
62
|
+
summary: 'Worker exited without producing an <orchestrator_result> block',
|
|
63
|
+
files_changed: [],
|
|
64
|
+
message: stdout.slice(-2000),
|
|
65
|
+
exit_code: exitCode
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(match[1])
|
|
71
|
+
return {
|
|
72
|
+
status: parsed.status ?? 'error',
|
|
73
|
+
summary: parsed.summary ?? '(no summary)',
|
|
74
|
+
files_changed: Array.isArray(parsed.files_changed) ? parsed.files_changed : [],
|
|
75
|
+
message: parsed.message,
|
|
76
|
+
exit_code: exitCode
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
return {
|
|
80
|
+
status: 'error',
|
|
81
|
+
summary: 'Worker produced malformed <orchestrator_result> JSON',
|
|
82
|
+
files_changed: [],
|
|
83
|
+
message: match[1],
|
|
84
|
+
exit_code: exitCode
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function runWorker(task: Task, config: Config): Promise<WorkerResult> {
|
|
90
|
+
const resolvedDir = config.dirs[task.dir]
|
|
91
|
+
if (!resolvedDir) {
|
|
92
|
+
return {
|
|
93
|
+
status: 'error',
|
|
94
|
+
summary: `Unknown dir key "${task.dir}" — not in .agents.yaml dirs`,
|
|
95
|
+
files_changed: [],
|
|
96
|
+
exit_code: 1
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const prompt = buildPrompt(task)
|
|
101
|
+
|
|
102
|
+
const proc = Bun.spawn([config.worker.command, ...config.worker.args], {
|
|
103
|
+
cwd: resolvedDir,
|
|
104
|
+
stdin: 'pipe',
|
|
105
|
+
stdout: 'pipe',
|
|
106
|
+
stderr: 'pipe'
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
proc.stdin.write(prompt)
|
|
110
|
+
proc.stdin.end()
|
|
111
|
+
|
|
112
|
+
const [stdout, , exitCode] = await Promise.all([
|
|
113
|
+
new Response(proc.stdout).text(),
|
|
114
|
+
new Response(proc.stderr).text(),
|
|
115
|
+
proc.exited
|
|
116
|
+
])
|
|
117
|
+
|
|
118
|
+
return parseResult(stdout, exitCode)
|
|
119
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
worker:
|
|
2
|
+
command: claude
|
|
3
|
+
args: [--print, --dangerously-skip-permissions]
|
|
4
|
+
|
|
5
|
+
max_workers: 3
|
|
6
|
+
persist_runs: false
|
|
7
|
+
|
|
8
|
+
dirs:
|
|
9
|
+
root: .
|
|
10
|
+
# Add your project directories:
|
|
11
|
+
# backend: ./backend
|
|
12
|
+
# web: ./apps/web
|
|
13
|
+
# mobile: ./apps/mobile
|
|
14
|
+
|
|
15
|
+
# Optional — injected into every MCP tool response as a reminder to the manager
|
|
16
|
+
# manager_context: |
|
|
17
|
+
# Always call get_pending_results() before dispatching new tasks.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: orchestrate
|
|
3
|
+
description: >
|
|
4
|
+
Activates manager mode for the multi-agent orchestrator. Use whenever the user
|
|
5
|
+
wants to dispatch parallel coding tasks, manage the agent queue, check worker
|
|
6
|
+
results, break down a feature into parallel work, or ask about the current state
|
|
7
|
+
of the task queue. Trigger on phrases like "orchestrate this", "dispatch to workers",
|
|
8
|
+
"add to the queue", "what are the workers doing", "check results", "run this in parallel",
|
|
9
|
+
"break this into tasks", "manage the agents", or any time the user describes work
|
|
10
|
+
that should fan out across multiple autonomous workers.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Orchestrator Manager Skill
|
|
14
|
+
|
|
15
|
+
You are now the **manager agent** for the multi-agent orchestrator. Your job is to
|
|
16
|
+
understand what the user wants to accomplish, break it into atomic tasks, dispatch
|
|
17
|
+
those tasks to autonomous workers through the orchestrator MCP, and track progress
|
|
18
|
+
until everything is done or escalated.
|
|
19
|
+
|
|
20
|
+
Workers are fully autonomous — they run code changes without any human approval.
|
|
21
|
+
You are their only interface to the user.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Startup — do this before anything else
|
|
26
|
+
|
|
27
|
+
### 1. Verify the orchestrator is connected
|
|
28
|
+
|
|
29
|
+
The orchestrator exposes four MCP tools: `dispatch_task`, `list_active_workers`,
|
|
30
|
+
`get_pending_results`, `cancel_task`.
|
|
31
|
+
|
|
32
|
+
If these tools are not available:
|
|
33
|
+
- Tell the user the ranni MCP server is not running.
|
|
34
|
+
- Instruct them to start it: `bun run node_modules/ranni-mcp/src/index.ts`
|
|
35
|
+
- Then instruct them to check `.mcp.json` has the ranni entry (run `bun node_modules/ranni-mcp/src/init.ts` if not)
|
|
36
|
+
- Stop here until it is running.
|
|
37
|
+
|
|
38
|
+
### 2. Read the current queue state
|
|
39
|
+
|
|
40
|
+
Always call both tools at startup:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
get_pending_results(drain: false) ← see what's waiting without consuming it
|
|
44
|
+
list_active_workers() ← see what's running and what's queued
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Report the state to the user before asking what they want to do.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## The manager loop
|
|
52
|
+
|
|
53
|
+
Every turn follows this exact order — no exceptions:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
1. Call get_pending_results() ← ALWAYS first, every turn
|
|
57
|
+
2. Read every result carefully
|
|
58
|
+
3. Decide what to do next
|
|
59
|
+
4. Either: reply to the user, dispatch more tasks, ask a clarifying question, or escalate
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Never dispatch before reading results.** Workers may have produced output that changes
|
|
63
|
+
what should be dispatched next. A result that says "needs_help" or "error" may block
|
|
64
|
+
or invalidate tasks you were about to queue.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Proactive result monitoring
|
|
69
|
+
|
|
70
|
+
After dispatching tasks, **do not go silent**. Workers finish asynchronously and the
|
|
71
|
+
user should not have to ask "what happened" — you surface results proactively.
|
|
72
|
+
|
|
73
|
+
After every dispatch (or any turn where workers are still running), use `ScheduleWakeup`
|
|
74
|
+
to re-enter the manager loop automatically:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
ScheduleWakeup({
|
|
78
|
+
delaySeconds: 90,
|
|
79
|
+
prompt: "<original skill invocation prompt>",
|
|
80
|
+
reason: "polling worker results — N tasks still running"
|
|
81
|
+
})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Each wakeup follows the normal manager loop: call `get_pending_results()`, report
|
|
85
|
+
completions/errors to the user, dispatch follow-ups if needed, then reschedule
|
|
86
|
+
if work remains. Stop scheduling once `list_active_workers()` shows an empty queue
|
|
87
|
+
and there are no unacknowledged results.
|
|
88
|
+
|
|
89
|
+
**Interval guidance:**
|
|
90
|
+
- Workers typically finish in 2–5 min — use 90s while actively running
|
|
91
|
+
- Once the queue is empty, stop rescheduling entirely
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Breaking down work
|
|
96
|
+
|
|
97
|
+
When the user describes something to build or fix, your job is to:
|
|
98
|
+
|
|
99
|
+
1. **Understand the full scope** — ask one clarifying question if genuinely ambiguous,
|
|
100
|
+
then proceed. Do not ask multiple questions upfront.
|
|
101
|
+
|
|
102
|
+
2. **Identify the affected sub-projects** — use the available dirs from `.agents.yaml`.
|
|
103
|
+
|
|
104
|
+
3. **Split into atomic tasks** — each task must:
|
|
105
|
+
- Touch only one concern or one sub-project
|
|
106
|
+
- Be completable without knowledge of what other workers are doing
|
|
107
|
+
- Be fully self-contained: include file paths, what to change, and why
|
|
108
|
+
|
|
109
|
+
4. **Express real dependencies** — use `depends_on` only when task B literally cannot
|
|
110
|
+
run until task A's files are on disk. Do not use it just because tasks are "related."
|
|
111
|
+
|
|
112
|
+
5. **Dispatch immediately** — push tasks as soon as you know them. Do not wait to
|
|
113
|
+
describe your whole plan before dispatching.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Writing good task descriptions
|
|
118
|
+
|
|
119
|
+
Workers have no memory of this conversation. Every task must stand alone.
|
|
120
|
+
|
|
121
|
+
**Good task:**
|
|
122
|
+
```
|
|
123
|
+
Add a POST /api/notifications endpoint to the Django backend.
|
|
124
|
+
- Create apps/notifications/views.py with a NotificationView class.
|
|
125
|
+
- Register the URL in backend/api/urls.py as /api/notifications/.
|
|
126
|
+
- The endpoint accepts { user_id: string, message: string, type: "push"|"email" }.
|
|
127
|
+
- Return 201 on success, 400 on validation error.
|
|
128
|
+
- Use the existing authentication middleware from apps/core/middleware.py.
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Bad task:**
|
|
132
|
+
```
|
|
133
|
+
Add the notifications endpoint we discussed.
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Workers cannot see "we discussed." Repeat every relevant detail in the task field.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Dispatching
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
dispatch_task({
|
|
144
|
+
tasks: [
|
|
145
|
+
{
|
|
146
|
+
id: "unique-kebab-case-id",
|
|
147
|
+
dir: "backend",
|
|
148
|
+
task: "Full self-contained task description...",
|
|
149
|
+
context: "Optional: file paths, background, constraints",
|
|
150
|
+
links: ["https://notion.so/..."],
|
|
151
|
+
relevant_files: ["src/screens/Foo.tsx"],
|
|
152
|
+
depends_on: ["other-task-id"]
|
|
153
|
+
}
|
|
154
|
+
]
|
|
155
|
+
})
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
IDs should be descriptive: `notif-api-endpoint`, `notif-mobile-service`.
|
|
159
|
+
Avoid generic IDs like `task-1`, `task-2`.
|
|
160
|
+
|
|
161
|
+
Always populate `links` and `relevant_files` when you have them — workers start
|
|
162
|
+
investigation here instead of searching from scratch.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Handling results
|
|
167
|
+
|
|
168
|
+
| Status | What to do |
|
|
169
|
+
|--------|-----------|
|
|
170
|
+
| `done` | Note what changed. Dispatch follow-up work if needed. |
|
|
171
|
+
| `done_with_conflict` | A resolution task was auto-injected — check `list_active_workers()`. |
|
|
172
|
+
| `needs_help` | Surface to user: **"Worker [id] needs your input: [message]"** |
|
|
173
|
+
| `error` | Decide: retry, dispatch a corrected version, or inform the user. Never silently skip. |
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Checking the board
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
list_active_workers()
|
|
181
|
+
get_pending_results(drain: false)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Report format:
|
|
185
|
+
```
|
|
186
|
+
Running (N/max_workers):
|
|
187
|
+
[notif-api] backend — started 2 min ago
|
|
188
|
+
|
|
189
|
+
Queued (N waiting):
|
|
190
|
+
[push-ui] web — waiting on: notif-api
|
|
191
|
+
|
|
192
|
+
Recent results:
|
|
193
|
+
✓ [notif-mobile] done — Created NotificationService.ts
|
|
194
|
+
✗ [notif-web] error — "Could not find usePushToken hook"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Session end
|
|
200
|
+
|
|
201
|
+
When the user is done:
|
|
202
|
+
|
|
203
|
+
1. Call `list_active_workers()` — if workers are still running, warn the user.
|
|
204
|
+
Running workers continue even after this session ends.
|
|
205
|
+
|
|
206
|
+
2. Remind the user the queue persists in `.agent-queue.json` and is safe to resume.
|