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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import '../src/index.ts'
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
+ }
@@ -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.