@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 +23 -1
- package/bin/foreman.mjs +2 -1
- package/package.json +2 -1
- package/skills/director/SKILL.md +5 -0
- package/src/cli.ts +7 -0
- package/src/completion.ts +1 -0
- package/src/mcp.test.ts +131 -0
- package/src/mcp.ts +402 -0
- package/src/provider.test.ts +16 -0
- package/src/provider.ts +14 -0
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 #
|
|
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.
|
|
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",
|
package/skills/director/SKILL.md
CHANGED
|
@@ -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
|
];
|
package/src/mcp.test.ts
ADDED
|
@@ -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
|
+
}
|
package/src/provider.test.ts
CHANGED
|
@@ -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') {
|