@amenophis1er/foreman 0.1.10 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -230,12 +230,34 @@ result.
230
230
  ```sh
231
231
  npm ci && npm run setup # dependencies, then the dashboard build
232
232
  npm start # serves http://localhost:4177
233
- npm test # 364 tests, node:test
233
+ npm test # 373 tests, node:test
234
234
  npm run typecheck # server and dashboard
235
235
  npm run dev # API + Vite together
236
236
  scripts/dev-restart.sh # restarts the server only when nothing would be lost
237
237
  ```
238
238
 
239
+ ## Use Foreman from another agent
240
+
241
+ `foreman mcp` serves Foreman's tools over stdio, so a Claude Code session, Codex
242
+ or Antigravity can watch and launch missions without a polling loop:
243
+
244
+ ```sh
245
+ claude mcp add foreman -- foreman mcp
246
+ codex mcp add foreman -- foreman mcp
247
+ agy mcp add foreman -- foreman mcp
248
+ ```
249
+
250
+ Tools: `fleet_status`, `list_runs`, `run_status` (with `wait_seconds`: one call
251
+ that returns when the run changes), `run_transcript`, `mission_doc`,
252
+ `project_memory`, `search_runs`, `doctor`, `link_project` (folder or Git URL),
253
+ `start_mission`, `steer`. It talks to the running server at `FOREMAN_URL`
254
+ (default `http://localhost:4177`) and has no logic of its own.
255
+
256
+ Deliberately absent: approving or denying, answering the director's questions,
257
+ interrupt, resume, raising a budget, opening a pull request, settings and keys.
258
+ Those are the moments Foreman exists to put a human in; `run_status` says when
259
+ a run needs one, and with what, so the agent's job is to send you to decide.
260
+
239
261
  To run a checkout beside an installed Foreman on the same machine, give it its
240
262
  own port and leave the bot to the installed one:
241
263
 
package/bin/foreman.mjs CHANGED
@@ -34,6 +34,7 @@ const USAGE = `foreman ${pkg.version}
34
34
  foreman uninstall Remove the service and the background server; keeps ~/.foreman
35
35
  foreman uninstall --purge --yes …and delete ~/.foreman (every run's history) too
36
36
  foreman completion install Tab-completion for these commands (zsh, bash, fish); or "completion zsh" to print it
37
+ foreman mcp Serve Foreman's tools to another agent over stdio (claude mcp add foreman -- foreman mcp)
37
38
  foreman --version | --help
38
39
 
39
40
  Environment:
@@ -56,7 +57,7 @@ if (command === '--help' || command === '-h' || command === 'help') {
56
57
  } else if (command === 'start') {
57
58
  register();
58
59
  await import(new URL('../src/server.ts', import.meta.url).href);
59
- } else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall', 'update', 'completion'].includes(command)) {
60
+ } else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall', 'update', 'completion', 'mcp'].includes(command)) {
60
61
  register();
61
62
  const { runCli } = await import(new URL('../src/cli.ts', import.meta.url).href);
62
63
  process.exitCode = await runCli(command, rest, { version: pkg.version, bin: new URL(import.meta.url) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amenophis1er/foreman",
3
- "version": "0.1.10",
3
+ "version": "0.1.13",
4
4
  "description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
5
5
  "keywords": [
6
6
  "claude",
@@ -51,6 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@anthropic-ai/claude-agent-sdk": "^0.3.259",
54
+ "@modelcontextprotocol/sdk": "1.30.0",
54
55
  "@playwright/mcp": "^0.0.80",
55
56
  "qrcode": "^1.5.4",
56
57
  "tsx": "^4.23.13",
@@ -5,6 +5,11 @@ description: Kick off a Foreman mission for the current project — ensures the
5
5
 
6
6
  # /director — launch a Foreman mission from this session
7
7
 
8
+ > Prefer the MCP: `claude mcp add foreman -- foreman mcp` gives this session
9
+ > `start_mission`, `run_status` (with a wait, so no polling) and the rest as
10
+ > tools, with the same governance. The steps below are the curl fallback for a
11
+ > session without it.
12
+
8
13
  You are the launcher only. The mission itself runs in Foreman's own director
9
14
  and worker sessions with budgets, approval cards, and MISSION.md governance;
10
15
  the human supervises from the dashboard, not from this session. Do NOT do the
package/src/cli.ts CHANGED
@@ -498,6 +498,13 @@ export async function runCli(command: string, rest: string[], ctx: { version: st
498
498
  case 'uninstall': return uninstall(rest);
499
499
  case 'status': return status();
500
500
  case 'doctor': return doctor();
501
+ case 'mcp': {
502
+ // Stdio is the protocol channel: nothing else may print there.
503
+ const { serveMcp } = await import('./mcp.js');
504
+ await serveMcp();
505
+ await new Promise(() => {}); // until the client closes the pipe
506
+ return 0;
507
+ }
501
508
  case 'completion': {
502
509
  const { completionScript, detectShell, installCompletion } = await import('./completion.js');
503
510
  const arg = rest[0];
package/src/completion.ts CHANGED
@@ -25,6 +25,7 @@ export const COMMANDS: Array<[string, string]> = [
25
25
  ['service', 'Keep Foreman running at login'],
26
26
  ['uninstall', 'Remove the service and the background server'],
27
27
  ['completion', 'Shell completion: zsh, bash, fish, or install'],
28
+ ['mcp', 'Serve Foreman\'s tools to another agent over stdio'],
28
29
  ['help', 'Show usage'],
29
30
  ['version', 'Print the version'],
30
31
  ];
@@ -0,0 +1,131 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { doneWhen, eventLine, foremanTools, isForemanCheckout, waitForRunEvent } from './mcp.js';
7
+
8
+ /** A Foreman server as the tools see it: routes answered from a table, requests recorded. */
9
+ function fakeServer(routes: Record<string, unknown | ((body: unknown) => unknown)>) {
10
+ const calls: Array<{ method: string; path: string; body?: unknown }> = [];
11
+ const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
12
+ const url = new URL(String(input));
13
+ const key = `${init?.method ?? 'GET'} ${url.pathname}${url.search}`;
14
+ const keyNoQuery = `${init?.method ?? 'GET'} ${url.pathname}`;
15
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined;
16
+ calls.push({ method: init?.method ?? 'GET', path: url.pathname + url.search, body });
17
+ const hit = key in routes ? routes[key] : routes[keyNoQuery];
18
+ if (hit === undefined) return new Response(JSON.stringify({ error: 'not found' }), { status: 404 });
19
+ const val = typeof hit === 'function' ? (hit as (b: unknown) => unknown)(body) : hit;
20
+ if (val instanceof Response) return val;
21
+ return new Response(JSON.stringify(val), { status: 200, headers: { 'content-type': 'application/json' } });
22
+ }) as unknown as typeof fetch;
23
+ return { fetchImpl, calls };
24
+ }
25
+
26
+ const run = (o: Record<string, unknown> = {}) => ({
27
+ id: 'r1', projectId: 'p1', mission: 'Fix the thing', title: 'Fix', status: 'running', costUsd: 1.25, budgetUsd: 5,
28
+ costBasis: 'priced', createdAt: 1, directorModel: 'fable', workerModel: 'opus',
29
+ workers: [{ id: 'worker-1', status: 'done', costUsd: 0.4, task: 'write tests' }],
30
+ git: { branch: 'foreman/fix-r1', base: 'main' }, ...o,
31
+ });
32
+ const tool = (tools: ReturnType<typeof foremanTools>, name: string) => tools.find((t) => t.name === name)!;
33
+
34
+ test('fleet_status reads /projects and says what needs a human', async () => {
35
+ const { fetchImpl } = fakeServer({
36
+ 'GET /projects': { version: '0.1.12', projects: [
37
+ { id: 'p1', name: 'app', folder: '/x/app', activeRun: run(), lastRun: null, pendingPermissions: 1, pendingQuestions: 0, git: { branch: 'main' } },
38
+ { id: 'p2', name: 'lib', folder: '/x/lib', activeRun: null, lastRun: { id: 'r0', mission: 'Old', status: 'done', costUsd: 2, createdAt: 1 }, pendingPermissions: 0, pendingQuestions: 0 },
39
+ ] },
40
+ });
41
+ const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'fleet_status').run({});
42
+ assert.match(r.text, /app \(p1\).*main\n running: r1 · running · \$1\.25 of \$5\.00/);
43
+ assert.match(r.text, /NEEDS YOU: 1 pending/);
44
+ assert.match(r.text, /lib \(p2\)[^\n]*\n idle · last: done · \$2\.00/);
45
+ });
46
+
47
+ test('run_status: crew, DONE WHEN from the mission doc, needs, and no wait on a finished run', async () => {
48
+ const { fetchImpl, calls } = fakeServer({
49
+ 'GET /runs': { runs: [run({ status: 'interrupted', stopReason: 'budget' })] },
50
+ 'GET /projects': { projects: [{ id: 'p1', name: 'app', folder: '/x/app', activeRun: null, lastRun: null, needs: [] }] },
51
+ 'GET /missiondoc': { doc: '# M\n## DONE WHEN\n- [x] tests pass\n- [ ] docs updated\n' },
52
+ });
53
+ const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_status').run({ runId: 'r1', wait_seconds: 30 });
54
+ assert.match(r.text, /stopped at its budget cap/);
55
+ assert.match(r.text, /DONE WHEN 1\/2\n open: docs updated/);
56
+ assert.match(r.text, /worker-1 · done/);
57
+ assert.match(r.text, /changed: no/);
58
+ assert.ok(!calls.some((c) => c.path.startsWith('/events')), 'a finished run is never waited on');
59
+ });
60
+
61
+ test('run_status with a wait subscribes to /events and returns on the first event for that run', async () => {
62
+ const sse = new ReadableStream<Uint8Array>({
63
+ start(c) {
64
+ const enc = new TextEncoder();
65
+ c.enqueue(enc.encode(': connected\n\n'));
66
+ c.enqueue(enc.encode('event: cost\ndata: {"runId":"other","projectId":"p9","data":{}}\n\n'));
67
+ c.enqueue(enc.encode('event: worker_started\ndata: {"runId":"r1","projectId":"p1","data":{"id":"worker-2"}}\n\n'));
68
+ },
69
+ });
70
+ const { fetchImpl } = fakeServer({
71
+ 'GET /runs': { runs: [run()] },
72
+ 'GET /projects': { projects: [{ id: 'p1', name: 'app', folder: '/x/app', activeRun: run(), needs: [{ kind: 'perm', id: 'a1', runId: 'r1', text: 'director wants Bash — rm -rf dist' }] }] },
73
+ 'GET /missiondoc': { doc: '' },
74
+ 'GET /events': new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } }),
75
+ });
76
+ const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_status').run({ runId: 'r1', wait_seconds: 5 });
77
+ assert.match(r.text, /changed: yes/);
78
+ assert.match(r.text, /NEEDS YOU \(1\) — only a human can answer/);
79
+ assert.match(r.text, /\[perm\] director wants Bash/);
80
+ });
81
+
82
+ test('waitForRunEvent gives up at the timeout when nothing arrives for the run', async () => {
83
+ const quiet = new ReadableStream<Uint8Array>({ start(c) { c.enqueue(new TextEncoder().encode(': connected\n\n')); } });
84
+ const { fetchImpl } = fakeServer({ 'GET /events': new Response(quiet, { status: 200 }) });
85
+ const t0 = Date.now();
86
+ assert.equal(await waitForRunEvent('http://f', 'r1', 1, fetchImpl), false);
87
+ assert.ok(Date.now() - t0 >= 900, 'waited about the timeout');
88
+ });
89
+
90
+ test('start_mission: 409 is a fact, the run id is read back, and Foreman itself is refused', async () => {
91
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'foreman-mcp-'));
92
+ const self = path.join(dir, 'foreman'); await mkdir(path.join(self, 'src'), { recursive: true });
93
+ await writeFile(path.join(self, 'package.json'), JSON.stringify({ name: 'x', bin: { foreman: 'bin/foreman.mjs' } }));
94
+ assert.equal(isForemanCheckout(self), true);
95
+ assert.equal(isForemanCheckout(dir), false);
96
+ let started = false;
97
+ const { fetchImpl, calls } = fakeServer({
98
+ 'GET /projects': { projects: [{ id: 'p1', name: 'app', folder: dir, activeRun: null }, { id: 'pf', name: 'foreman', folder: self, activeRun: null }] },
99
+ 'POST /run': (b: unknown) => { started = true; return (b as { projectId: string }).projectId === 'busy' ? new Response(JSON.stringify({ error: 'busy' }), { status: 409 }) : { ok: true }; },
100
+ 'GET /runs': () => ({ runs: started ? [run({ status: 'running', budgetUsd: 3 })] : [] }),
101
+ });
102
+ const tools = foremanTools({ base: 'http://f', fetchImpl });
103
+ const refused = await tool(tools, 'start_mission').run({ projectId: 'pf', brief: 'Harden the server please', budgetUsd: 3 });
104
+ assert.match(refused.text, /Refused: this folder is Foreman itself/);
105
+ assert.ok(!calls.some((c) => c.method === 'POST'), 'nothing was posted for the refused one');
106
+ const ok = await tool(tools, 'start_mission').run({ projectId: 'p1', brief: 'Fix the flaky test in ci', budgetUsd: 3, worker: 'sonnet' });
107
+ assert.match(ok.text, /Started r1 on app, cap \$3\.00/);
108
+ assert.deepEqual(calls.find((c) => c.method === 'POST')!.body, { projectId: 'p1', mission: 'Fix the flaky test in ci', budgetUsd: 3, workerModel: 'sonnet' });
109
+ await rm(dir, { recursive: true, force: true });
110
+ });
111
+
112
+ test('the tool set has no human-only actions', () => {
113
+ const names = foremanTools({ base: 'http://f' }).map((t) => t.name);
114
+ for (const forbidden of ['approve', 'deny', 'permission', 'answer', 'interrupt', 'resume', 'budget', 'pull_request', 'open_pr', 'settings', 'key']) {
115
+ assert.ok(!names.some((n) => n.split('_').includes(forbidden) || n === forbidden), `${forbidden} must not be a tool`);
116
+ }
117
+ assert.deepEqual(names, ['fleet_status', 'list_runs', 'run_status', 'run_transcript', 'mission_doc', 'project_memory', 'search_runs', 'doctor', 'link_project', 'start_mission', 'steer']);
118
+ });
119
+
120
+ test('a server that is not there is said in one sentence with the start command', async () => {
121
+ const fetchImpl = (async () => { throw new Error('ECONNREFUSED'); }) as unknown as typeof fetch;
122
+ await assert.rejects(tool(foremanTools({ base: 'http://localhost:4177', fetchImpl }), 'fleet_status').run({}), /not answering at http:\/\/localhost:4177 .*Start it with `foreman`/);
123
+ });
124
+
125
+ test('eventLine and doneWhen condense what the transcript and the doc say', () => {
126
+ assert.equal(eventLine('permission_request', { agent: 'director', toolName: 'Bash', decisionReason: 'leaves the folder' }), 'NEEDS YOU — director wants Bash (leaves the folder)');
127
+ assert.equal(eventLine('message', { agent: 'worker-1', msg: { type: 'assistant', message: { content: [{ type: 'text', text: 'Done.\n\nAll green.' }] } } }), 'worker-1: Done. All green.');
128
+ assert.equal(eventLine('message', { agent: 'w', msg: { type: 'user' } }), null);
129
+ assert.equal(eventLine('cost', {}), null);
130
+ assert.deepEqual(doneWhen('- [ ] a\n* [x] b\n- [X] c\nnot a box'), { done: ['b', 'c'], open: ['a'] });
131
+ });
package/src/mcp.ts ADDED
@@ -0,0 +1,402 @@
1
+ /**
2
+ * `foreman mcp` — Foreman for other agents.
3
+ *
4
+ * A stdio MCP server another agent starts on demand (Claude Code, Codex,
5
+ * Antigravity: `<client> mcp add foreman -- foreman mcp`). Deliberately thin:
6
+ * every tool is a call to the running Foreman server over the same REST and
7
+ * event stream the dashboard uses, so policy lives in one place — the server
8
+ * — and this file has no business logic of its own.
9
+ *
10
+ * What it offers is what an agent watching or launching missions needs: the
11
+ * fleet, runs, a run's status with a `wait` (one call that returns when
12
+ * something changes, instead of a polling loop), the transcript, the mission
13
+ * doc and memory, linking a project, starting a mission, steering a director.
14
+ *
15
+ * What it does not offer, on purpose: approving or denying, answering the
16
+ * director's questions, interrupt, resume, raising a budget, opening a pull
17
+ * request, settings and keys. Those are the moments Foreman exists to put a
18
+ * human in; `run_status` says when a run needs one, and with what, so the
19
+ * agent's job is to send the human to decide, not to decide.
20
+ */
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { z } from 'zod';
24
+
25
+ export interface ToolResult {
26
+ /** What the model reads. */
27
+ text: string;
28
+ /** The same facts, structured. */
29
+ data?: unknown;
30
+ }
31
+
32
+ export interface ToolDef {
33
+ name: string;
34
+ description: string;
35
+ schema: z.ZodRawShape;
36
+ run: (args: Record<string, unknown>) => Promise<ToolResult>;
37
+ }
38
+
39
+ export interface ForemanClientOptions {
40
+ /** Where Foreman answers; `FOREMAN_URL` or http://127.0.0.1:4177. */
41
+ base: string;
42
+ fetchImpl?: typeof fetch;
43
+ /** Longest a `wait` may block, in seconds. */
44
+ maxWaitSeconds?: number;
45
+ }
46
+
47
+ const usd = (n: number) => `$${n.toFixed(2)}`;
48
+
49
+ /** A folder that is Foreman itself: never a mission target (the oversight rule). */
50
+ export function isForemanCheckout(folder: string): boolean {
51
+ try {
52
+ const pkg = JSON.parse(fs.readFileSync(path.join(folder, 'package.json'), 'utf8')) as { bin?: Record<string, string> | string };
53
+ if (pkg.bin && typeof pkg.bin === 'object' && 'foreman' in pkg.bin) return true;
54
+ } catch { /* no package.json, or not ours */ }
55
+ return fs.existsSync(path.join(folder, 'src', 'orchestrator.ts')) && fs.existsSync(path.join(folder, 'src', 'policy.ts'));
56
+ }
57
+
58
+ interface RunSummary {
59
+ id: string; projectId?: string; mission: string; title?: string; status: string;
60
+ costUsd: number; budgetUsd: number; costBasis?: string; createdAt: number; endedAt?: number;
61
+ directorModel?: string; workerModel?: string; resumes?: number; stopReason?: string;
62
+ workers?: Array<{ id: string; status: string; costUsd: number; task: string }>;
63
+ git?: { branch: string; base: string; commits?: number; pr?: string; prState?: string };
64
+ usage?: { inputTokens: number; outputTokens: number };
65
+ }
66
+ interface Need { kind: string; id: string; runId?: string; text: string; options?: string[]; toolName?: string; since?: number }
67
+ interface ProjectCard {
68
+ id: string; name: string; folder: string; activeRun: RunSummary | null;
69
+ lastRun: { id: string; title?: string; mission: string; status: string; costUsd: number; createdAt: number } | null;
70
+ pendingPermissions: number; pendingQuestions: number; needs?: Need[]; git?: { branch?: string; dirty?: boolean } | null;
71
+ }
72
+
73
+ /** One line for a run, the way the fleet board says it. */
74
+ function runLine(r: RunSummary): string {
75
+ const cost = r.costBasis && r.costBasis !== 'priced' ? `${r.costBasis}` : `${usd(r.costUsd)} of ${usd(r.budgetUsd)}`;
76
+ return `${r.id} · ${r.status}${r.stopReason ? ` (stopped at its ${r.stopReason} cap)` : ''} · ${cost} · ${r.title || r.mission.slice(0, 80)}`;
77
+ }
78
+
79
+ /** A transcript event as one line; null for noise. */
80
+ export function eventLine(event: string, d: Record<string, unknown>): string | null {
81
+ const s = (k: string) => (typeof d[k] === 'string' ? (d[k] as string) : '');
82
+ switch (event) {
83
+ case 'run_started': return 'run started';
84
+ case 'run_resumed': return 'run resumed';
85
+ case 'run_finished': return `run finished: ${s('status')}`;
86
+ case 'worker_started': return `${s('id')} started: ${s('task').slice(0, 120)}`;
87
+ case 'worker_finished': return `${s('id')} finished: ${s('status')}`;
88
+ case 'worker_progress': return `${s('id')}: ${d.blocked ? `BLOCKED — ${String(d.blocked)}` : s('status')}`;
89
+ case 'permission_request': return `NEEDS YOU — ${s('agent')} wants ${s('toolName')}${s('decisionReason') ? ` (${s('decisionReason')})` : ''}`;
90
+ case 'permission_resolved': return `approval ${s('behavior')}`;
91
+ case 'permission_timeout': return 'approval timed out (unattended default applied)';
92
+ case 'question': return `NEEDS YOU — director asks: ${s('question')}`;
93
+ case 'question_answered': return 'question answered';
94
+ case 'budget_alert': case 'budget_stop': case 'models_changed': case 'settings_changed': case 'mission_incomplete':
95
+ return s('text') || s('reason') || (Array.isArray(d.changes) ? (d.changes as string[]).join('; ') : null) || event;
96
+ case 'git_branch': return `on branch ${s('branch')}`;
97
+ case 'git_committed': return s('text') || 'branch closed';
98
+ case 'pull_request': return s('url') ? `pull request: ${s('url')}` : null;
99
+ case 'memory_updated': return 'project memory rewritten';
100
+ case 'steer': return `operator → ${s('to')}: ${s('text')}`;
101
+ case 'message': {
102
+ const msg = d.msg as { type?: string; message?: { content?: Array<{ type: string; text?: string; name?: string }> } } | undefined;
103
+ if (msg?.type !== 'assistant') return null;
104
+ const block = msg.message?.content?.find((c) => c.type === 'text' && c.text) ?? msg.message?.content?.find((c) => c.type === 'tool_use');
105
+ if (!block) return null;
106
+ return block.type === 'text' ? `${s('agent')}: ${(block.text ?? '').replace(/\s+/g, ' ').slice(0, 200)}` : `${s('agent')} → ${block.name}`;
107
+ }
108
+ default: return null;
109
+ }
110
+ }
111
+
112
+ /** DONE WHEN lines from a mission doc: ticked and unticked. */
113
+ export function doneWhen(doc: string): { done: string[]; open: string[] } {
114
+ const done: string[] = []; const open: string[] = [];
115
+ for (const line of doc.split('\n')) {
116
+ const m = /^\s*[-*]\s*\[( |x|X)\]\s+(.*)$/.exec(line);
117
+ if (!m) continue;
118
+ (m[1].trim() ? done : open).push(m[2].trim());
119
+ }
120
+ return { done, open };
121
+ }
122
+
123
+ /**
124
+ * Waits for the next event on a run, over the server's own SSE stream. One
125
+ * subscription per call, filtered to the run; resolves true on the first
126
+ * event that names it, false at the timeout. Never polls.
127
+ */
128
+ export async function waitForRunEvent(base: string, runId: string, seconds: number, fetchImpl: typeof fetch): Promise<boolean> {
129
+ const ctl = new AbortController();
130
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
131
+ // The clock wins whatever the stream does: a fetch that ignores the abort
132
+ // signal, or a body that never yields, must not hold the caller past the
133
+ // seconds it asked for.
134
+ const timeout = new Promise<false>((resolve) => setTimeout(() => { ctl.abort(); void reader?.cancel().catch(() => {}); resolve(false); }, Math.max(1, seconds) * 1000));
135
+ const watch = (async (): Promise<boolean> => {
136
+ try {
137
+ const res = await fetchImpl(`${base}/events`, { signal: ctl.signal, headers: { accept: 'text/event-stream' } });
138
+ if (!res.ok || !res.body) return false;
139
+ reader = res.body.getReader();
140
+ const decoder = new TextDecoder();
141
+ let buf = '';
142
+ for (;;) {
143
+ const { value, done } = await reader.read();
144
+ if (done) return false;
145
+ buf += decoder.decode(value, { stream: true });
146
+ let nl: number;
147
+ while ((nl = buf.indexOf('\n')) >= 0) {
148
+ const line = buf.slice(0, nl).trimEnd();
149
+ buf = buf.slice(nl + 1);
150
+ if (!line.startsWith('data:')) continue;
151
+ try {
152
+ const env = JSON.parse(line.slice(5).trim()) as { runId?: string | null };
153
+ if (env.runId === runId) { ctl.abort(); void reader.cancel().catch(() => {}); return true; }
154
+ } catch { /* a comment or a partial frame */ }
155
+ }
156
+ }
157
+ } catch {
158
+ return false;
159
+ }
160
+ })();
161
+ return Promise.race([watch, timeout]);
162
+ }
163
+
164
+ /** The tools, as data — the MCP server registers them; the tests call them. */
165
+ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
166
+ const base = opts.base.replace(/\/+$/, '');
167
+ const f = opts.fetchImpl ?? fetch;
168
+ const maxWait = opts.maxWaitSeconds ?? 300;
169
+
170
+ async function get<T>(p: string): Promise<T> {
171
+ let res: Response;
172
+ try { res = await f(`${base}${p}`); } catch (err) {
173
+ throw new Error(`Foreman is not answering at ${base} (${err instanceof Error ? err.message : String(err)}). Start it with \`foreman\`, or set FOREMAN_URL.`);
174
+ }
175
+ if (!res.ok) throw new Error(`${p}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
176
+ return res.json() as Promise<T>;
177
+ }
178
+ async function post<T>(p: string, body: unknown): Promise<{ ok: boolean; status: number; json: T }> {
179
+ let res: Response;
180
+ try { res = await f(`${base}${p}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); } catch (err) {
181
+ throw new Error(`Foreman is not answering at ${base} (${err instanceof Error ? err.message : String(err)}). Start it with \`foreman\`, or set FOREMAN_URL.`);
182
+ }
183
+ const json = await res.json().catch(() => ({})) as T;
184
+ return { ok: res.ok, status: res.status, json };
185
+ }
186
+ async function findRun(runId: string): Promise<RunSummary> {
187
+ const { runs } = await get<{ runs: RunSummary[] }>('/runs');
188
+ const r = runs.find((x) => x.id === runId);
189
+ if (!r) throw new Error(`No run ${runId}. list_runs shows what exists.`);
190
+ return r;
191
+ }
192
+
193
+ const fleet: ToolDef = {
194
+ name: 'fleet_status',
195
+ description: 'Every linked project with what it is doing: the active mission (status, spend against cap, crew), what needs a human, and the last finished run. Start here.',
196
+ schema: {},
197
+ run: async () => {
198
+ const d = await get<{ projects: ProjectCard[]; authMode?: string; version?: string }>('/projects');
199
+ const lines = d.projects.map((p) => {
200
+ const a = p.activeRun;
201
+ const needs = (p.pendingPermissions ?? 0) + (p.pendingQuestions ?? 0);
202
+ const head = `${p.name} (${p.id}) — ${p.folder}${p.git?.branch ? ` · ${p.git.branch}` : ''}`;
203
+ if (a) return `${head}\n running: ${runLine(a)}${needs ? `\n NEEDS YOU: ${needs} pending — the human decides these on the dashboard or phone` : ''}`;
204
+ if (p.lastRun) return `${head}\n idle · last: ${p.lastRun.status} · ${usd(p.lastRun.costUsd)} · ${p.lastRun.title || p.lastRun.mission.slice(0, 80)}`;
205
+ return `${head}\n idle · no runs yet`;
206
+ });
207
+ return {
208
+ text: lines.length ? lines.join('\n') : 'No projects linked. link_project adds one.',
209
+ data: { version: d.version, authMode: d.authMode, projects: d.projects.map((p) => ({ id: p.id, name: p.name, folder: p.folder, branch: p.git?.branch, activeRun: p.activeRun && { id: p.activeRun.id, status: p.activeRun.status, costUsd: p.activeRun.costUsd, budgetUsd: p.activeRun.budgetUsd }, needs: p.needs ?? [], lastRun: p.lastRun })) },
210
+ };
211
+ },
212
+ };
213
+
214
+ const listRuns: ToolDef = {
215
+ name: 'list_runs',
216
+ description: 'Run summaries, newest first, optionally for one project.',
217
+ schema: { projectId: z.string().optional(), limit: z.number().int().min(1).max(100).default(20) },
218
+ run: async ({ projectId, limit }) => {
219
+ const { runs } = await get<{ runs: RunSummary[] }>(`/runs${projectId ? `?projectId=${encodeURIComponent(String(projectId))}` : ''}`);
220
+ const shown = runs.slice(0, Number(limit ?? 20));
221
+ return { text: shown.length ? shown.map(runLine).join('\n') : 'No runs.', data: { runs: shown } };
222
+ },
223
+ };
224
+
225
+ const runStatus: ToolDef = {
226
+ name: 'run_status',
227
+ description: 'One run: status, spend against cap, crew and their states, DONE WHEN ticks, what needs a human, branch and pull request. With wait_seconds > 0 it returns as soon as anything changes on that run (or at the timeout) — use it instead of polling.',
228
+ schema: { runId: z.string(), wait_seconds: z.number().int().min(0).max(maxWait).default(0) },
229
+ run: async ({ runId, wait_seconds }) => {
230
+ const id = String(runId);
231
+ let changed: boolean | undefined;
232
+ if (Number(wait_seconds) > 0) {
233
+ const before = await findRun(id);
234
+ if (before.status === 'running') changed = await waitForRunEvent(base, id, Number(wait_seconds), f);
235
+ else changed = false;
236
+ }
237
+ const r = await findRun(id);
238
+ const projects = (await get<{ projects: ProjectCard[] }>('/projects')).projects;
239
+ const card = projects.find((p) => p.activeRun?.id === id);
240
+ const needs = (card?.needs ?? []).filter((n) => !n.runId || n.runId === id);
241
+ const doc = await get<{ doc: string }>(`/missiondoc?run=${encodeURIComponent(id)}`).then((d) => d.doc).catch(() => '');
242
+ const dw = doneWhen(doc);
243
+ const workers = (r.workers ?? []).map((w) => ` ${w.id} · ${w.status} · ${usd(w.costUsd)} · ${w.task.slice(0, 80)}`);
244
+ const lines = [
245
+ runLine(r),
246
+ `director ${r.directorModel ?? 'default'} · workers ${r.workerModel ?? 'default'} · resumes ${r.resumes ?? 0}`,
247
+ r.git ? `branch ${r.git.branch} from ${r.git.base}${r.git.pr ? ` · PR ${r.git.pr}${r.git.prState ? ` (${r.git.prState})` : ''}` : ''}` : null,
248
+ dw.done.length + dw.open.length ? `DONE WHEN ${dw.done.length}/${dw.done.length + dw.open.length}${dw.open.length ? `\n open: ${dw.open.join('\n open: ')}` : ''}` : null,
249
+ workers.length ? `crew:\n${workers.join('\n')}` : null,
250
+ needs.length ? `NEEDS YOU (${needs.length}) — only a human can answer these, on the dashboard or the phone:\n${needs.map((n) => ` [${n.kind}] ${n.text}`).join('\n')}` : null,
251
+ changed !== undefined ? (changed ? 'changed: yes' : 'changed: no (timeout)') : null,
252
+ ].filter(Boolean);
253
+ return { text: lines.join('\n'), data: { run: r, doneWhen: dw, needs, changed } };
254
+ },
255
+ };
256
+
257
+ const transcript: ToolDef = {
258
+ name: 'run_transcript',
259
+ description: 'The run\'s recent events, one line each, oldest first. since_ts (ms) narrows to what happened after a moment you already read.',
260
+ schema: { runId: z.string(), since_ts: z.number().optional(), limit: z.number().int().min(1).max(500).default(50) },
261
+ run: async ({ runId, since_ts, limit }) => {
262
+ const { events } = await get<{ events: Array<{ ts: number; event: string; data: Record<string, unknown> }> }>(`/runs/${encodeURIComponent(String(runId))}/events`);
263
+ const lines: Array<{ ts: number; line: string }> = [];
264
+ for (const e of events) {
265
+ if (since_ts && e.ts <= Number(since_ts)) continue;
266
+ const line = eventLine(e.event, e.data ?? {});
267
+ if (line) lines.push({ ts: e.ts, line });
268
+ }
269
+ const shown = lines.slice(-Number(limit ?? 50));
270
+ return {
271
+ text: shown.length ? shown.map((l) => `${new Date(l.ts).toISOString().slice(11, 19)} ${l.line}`).join('\n') : 'Nothing yet.',
272
+ data: { events: shown, lastTs: shown.at(-1)?.ts ?? null },
273
+ };
274
+ },
275
+ };
276
+
277
+ const missionDoc: ToolDef = {
278
+ name: 'mission_doc',
279
+ description: 'The run\'s MISSION.md: DONE WHEN criteria, log, state — what the director itself keeps.',
280
+ schema: { runId: z.string() },
281
+ run: async ({ runId }) => {
282
+ const { doc } = await get<{ doc: string }>(`/missiondoc?run=${encodeURIComponent(String(runId))}`);
283
+ return { text: doc || 'MISSION.md not written yet.', data: { doc } };
284
+ },
285
+ };
286
+
287
+ const memory: ToolDef = {
288
+ name: 'project_memory',
289
+ description: 'The project\'s memory (.foreman/MEMORY.md): what earlier crews learned — how to run and test it, ports, traps.',
290
+ schema: { projectId: z.string() },
291
+ run: async ({ projectId }) => {
292
+ const d = await get<{ text: string; updatedAt?: number }>(`/projects/${encodeURIComponent(String(projectId))}/memory`);
293
+ return { text: d.text || 'No memory yet.', data: d };
294
+ },
295
+ };
296
+
297
+ const search: ToolDef = {
298
+ name: 'search_runs',
299
+ description: 'Runs across the fleet whose title, brief, project or folder match.',
300
+ schema: { q: z.string().min(1) },
301
+ run: async ({ q }) => {
302
+ const d = await get<{ runs: RunSummary[] }>(`/search?q=${encodeURIComponent(String(q))}`);
303
+ return { text: d.runs.length ? d.runs.map(runLine).join('\n') : 'No match.', data: d };
304
+ },
305
+ };
306
+
307
+ const doctor: ToolDef = {
308
+ name: 'doctor',
309
+ description: 'What this machine has for missions: credentials, providers, browser, reach — the same checks as `foreman doctor`.',
310
+ schema: {},
311
+ run: async () => {
312
+ const d = await get<{ checks: Array<{ name: string; status: string; detail: string; fix?: string }> }>('/doctor');
313
+ return { text: d.checks.map((c) => `${c.status === 'ok' ? '✓' : c.status === 'warn' ? '!' : '✗'} ${c.name}: ${c.detail}${c.fix && c.status !== 'ok' ? `\n ${c.fix}` : ''}`).join('\n'), data: d };
314
+ },
315
+ };
316
+
317
+ const link: ToolDef = {
318
+ name: 'link_project',
319
+ description: 'Link a folder on this machine as a project (idempotent), or clone a Git URL under the projects root and link it. Returns the project id.',
320
+ schema: { folder: z.string().optional(), git_url: z.string().optional(), branch: z.string().optional() },
321
+ run: async ({ folder, git_url, branch }) => {
322
+ if (git_url) {
323
+ const started = await post<{ id?: string; dest?: string; error?: string }>('/projects/clone', { url: git_url, branch });
324
+ if (!started.ok) return { text: `Could not start the clone: ${started.json.error ?? started.status}` };
325
+ for (let i = 0; i < 600; i++) {
326
+ const job = await get<{ state: string; progress: string; projectId?: string; error?: string }>(`/projects/clone/${started.json.id}`);
327
+ if (job.state === 'done') return { text: `Cloned to ${started.json.dest} and linked as project ${job.projectId}.`, data: { projectId: job.projectId, folder: started.json.dest } };
328
+ if (job.state === 'error') return { text: `Clone failed: ${job.error}` };
329
+ await new Promise((r) => setTimeout(r, 1000));
330
+ }
331
+ return { text: 'The clone is still running; check fleet_status in a minute.' };
332
+ }
333
+ if (!folder) return { text: 'Give a folder path or a git_url.' };
334
+ const abs = path.resolve(String(folder));
335
+ const r = await post<{ project?: { id: string; name: string; folder: string }; error?: string }>('/projects', { folder: abs });
336
+ if (!r.ok || !r.json.project) return { text: `Could not link ${abs}: ${r.json.error ?? r.status}` };
337
+ return { text: `Linked ${r.json.project.name} (${r.json.project.id}) at ${r.json.project.folder}.`, data: r.json.project };
338
+ },
339
+ };
340
+
341
+ const start: ToolDef = {
342
+ name: 'start_mission',
343
+ description: 'Start a mission on a project: the brief, a dollar cap, optional director/worker models and a browser. The mission runs in Foreman under its own governance; approvals and questions go to the human on the dashboard or phone, never through this tool. Follow with run_status(wait_seconds).',
344
+ schema: {
345
+ projectId: z.string(), brief: z.string().min(10), budgetUsd: z.number().positive(),
346
+ director: z.string().optional(), worker: z.string().optional(), browser: z.boolean().optional(),
347
+ },
348
+ run: async ({ projectId, brief, budgetUsd, director, worker, browser }) => {
349
+ const pid = String(projectId);
350
+ const projects = (await get<{ projects: ProjectCard[] }>('/projects')).projects;
351
+ const p = projects.find((x) => x.id === pid);
352
+ if (!p) return { text: `No project ${pid}. fleet_status lists them; link_project adds one.` };
353
+ if (isForemanCheckout(p.folder)) return { text: 'Refused: this folder is Foreman itself, and Foreman never runs missions on its own oversight infrastructure.' };
354
+ const r = await post<{ ok?: boolean; error?: string }>('/run', {
355
+ projectId: pid, mission: brief, budgetUsd, directorModel: director, workerModel: worker, browserTools: browser === true ? true : undefined,
356
+ });
357
+ if (r.status === 409) return { text: `${p.name} already has an active mission; see fleet_status. One mission per project at a time.` };
358
+ if (!r.ok) return { text: `Could not start: ${r.json.error ?? r.status}` };
359
+ // The run id lands a moment later; read it back so the caller can watch it.
360
+ for (let i = 0; i < 20; i++) {
361
+ const { runs } = await get<{ runs: RunSummary[] }>(`/runs?projectId=${encodeURIComponent(pid)}`);
362
+ const live = runs.find((x) => x.status === 'running');
363
+ if (live) return { text: `Started ${live.id} on ${p.name}, cap ${usd(live.budgetUsd)}. Watch it with run_status(runId, wait_seconds).`, data: { runId: live.id, projectId: pid } };
364
+ await new Promise((res) => setTimeout(res, 250));
365
+ }
366
+ return { text: `Started on ${p.name}; the run id was not visible yet — list_runs will show it.`, data: { projectId: pid } };
367
+ },
368
+ };
369
+
370
+ const steer: ToolDef = {
371
+ name: 'steer',
372
+ description: 'Send an operator note to a running director (a hint, a priority, a correction). Relays the human; it does not approve anything.',
373
+ schema: { runId: z.string(), text: z.string().min(1) },
374
+ run: async ({ runId, text }) => {
375
+ const r = await post<{ ok?: boolean; error?: string }>('/steer', { runId, text });
376
+ return { text: r.ok ? 'Delivered to the director for its next turn.' : `Could not steer: ${r.json.error ?? r.status}` };
377
+ },
378
+ };
379
+
380
+ return [fleet, listRuns, runStatus, transcript, missionDoc, memory, search, doctor, link, start, steer];
381
+ }
382
+
383
+ /** Runs the MCP server over stdio until the client goes away. Nothing may be written to stdout but the protocol. */
384
+ // 127.0.0.1 rather than localhost: a stray process on the IPv6 wildcard
385
+ // (a worker's dev server, once) answers `localhost` first in most resolvers
386
+ // and would shadow Foreman for this client too. FOREMAN_URL overrides.
387
+ export async function serveMcp(base = process.env.FOREMAN_URL || 'http://127.0.0.1:4177'): Promise<void> {
388
+ const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
389
+ const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
390
+ const server = new McpServer({ name: 'foreman', version: '1' });
391
+ for (const t of foremanTools({ base })) {
392
+ server.registerTool(t.name, { description: t.description, inputSchema: t.schema }, async (args: Record<string, unknown>) => {
393
+ try {
394
+ const r = await t.run(args ?? {});
395
+ return { content: [{ type: 'text' as const, text: r.text }], ...(r.data !== undefined ? { structuredContent: r.data as Record<string, unknown> } : {}) };
396
+ } catch (err) {
397
+ return { content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }], isError: true };
398
+ }
399
+ });
400
+ }
401
+ await server.connect(new StdioServerTransport());
402
+ }
@@ -364,3 +364,19 @@ test('a role provider carries the role’s model, so every alias resolves on its
364
364
  assert.equal(withRoleModel(bare, '').model, undefined);
365
365
  assert.equal(withRoleModel(bare, undefined), bare, 'no change returns the same object');
366
366
  });
367
+
368
+
369
+ test('agents do not inherit the server\'s own environment: PORT and FOREMAN_* are dropped', async () => {
370
+ const saved = { PORT: process.env.PORT, FOREMAN_HOME: process.env.FOREMAN_HOME, FOREMAN_BIND: process.env.FOREMAN_BIND };
371
+ process.env.PORT = '4177'; process.env.FOREMAN_HOME = '/tmp/fh'; process.env.FOREMAN_BIND = 'all';
372
+ try {
373
+ const p = await resolveProvider({ kind: 'claude-code' }, '/tmp/foreman-test-root');
374
+ const env = providerEnv(p).env ?? {};
375
+ assert.equal(env.PORT, undefined, 'a dev server a worker starts must not bind the dashboard port');
376
+ assert.equal(env.FOREMAN_HOME, undefined);
377
+ assert.equal(env.FOREMAN_BIND, undefined);
378
+ assert.ok(env.PATH, 'the rest of the environment survives');
379
+ } finally {
380
+ for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
381
+ }
382
+ });
package/src/provider.ts CHANGED
@@ -427,8 +427,22 @@ export interface AgentEnv {
427
427
  * programming error rather than a user error, and the failure mode it guards
428
428
  * against is a leaked token, so it fails loudly instead of degrading.
429
429
  */
430
+ /**
431
+ * The server's own knobs, which an agent must not inherit. `PORT` is the one
432
+ * that bit: the service sets it for the dashboard, an agent's dev server
433
+ * honoured it, bound Foreman's port on a wildcard address and shadowed the
434
+ * dashboard on localhost until it exited. The rest are Foreman's operating
435
+ * settings — none of them means anything to a project's own tooling, and a
436
+ * worker reading `FOREMAN_HOME` would only find somewhere it should not be.
437
+ */
438
+ const SERVER_ONLY_VARS = [
439
+ 'PORT', 'FOREMAN_SERVICES_PORT', 'FOREMAN_HOME', 'FOREMAN_BIND', 'FOREMAN_BROWSER',
440
+ 'FOREMAN_AUTH_MODE', 'FOREMAN_CLAUDE_CONFIG_DIR', 'FOREMAN_CLAUDE_EXECUTABLE', 'FOREMAN_NO_TELEGRAM', 'FOREMAN_LOG',
441
+ ];
442
+
430
443
  export function providerEnv(p: ResolvedProvider, gatewayUrl?: string): AgentEnv {
431
444
  const env: Record<string, string | undefined> = { ...process.env };
445
+ for (const v of SERVER_ONLY_VARS) delete env[v];
432
446
  env.CLAUDE_CONFIG_DIR = p.configDir;
433
447
 
434
448
  if (p.wire === 'anthropic-native') {