@amenophis1er/foreman 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.
Files changed (65) hide show
  1. package/DESIGN.md +408 -0
  2. package/LICENSE +15 -0
  3. package/README.md +133 -0
  4. package/bin/foreman.mjs +58 -0
  5. package/package.json +68 -0
  6. package/scripts/prepare.mjs +48 -0
  7. package/skills/director/SKILL.md +65 -0
  8. package/src/anthropic-models.ts +54 -0
  9. package/src/ask.test.ts +88 -0
  10. package/src/ask.ts +95 -0
  11. package/src/attachments.test.ts +33 -0
  12. package/src/attachments.ts +60 -0
  13. package/src/cli.test.ts +27 -0
  14. package/src/cli.ts +297 -0
  15. package/src/codex.test.ts +328 -0
  16. package/src/codex.ts +196 -0
  17. package/src/cost-basis.test.ts +76 -0
  18. package/src/deck.test.ts +402 -0
  19. package/src/deck.ts +892 -0
  20. package/src/fork.test.ts +31 -0
  21. package/src/gateway/ledger.cjs +326 -0
  22. package/src/gateway/ledger.test.ts +255 -0
  23. package/src/gateway/llm-gateway.cjs +1411 -0
  24. package/src/gateway/llm-gateway.test.ts +478 -0
  25. package/src/gateway.test.ts +226 -0
  26. package/src/gateway.ts +309 -0
  27. package/src/instance.ts +124 -0
  28. package/src/models.test.ts +147 -0
  29. package/src/models.ts +158 -0
  30. package/src/notify/commands.test.ts +28 -0
  31. package/src/notify/commands.ts +73 -0
  32. package/src/notify/telegram.ts +259 -0
  33. package/src/notify.test.ts +343 -0
  34. package/src/notify.ts +495 -0
  35. package/src/ollama.test.ts +49 -0
  36. package/src/ollama.ts +49 -0
  37. package/src/openai-prices.test.ts +58 -0
  38. package/src/openai-prices.ts +106 -0
  39. package/src/orchestrator.test.ts +1147 -0
  40. package/src/orchestrator.ts +2325 -0
  41. package/src/planner.test.ts +60 -0
  42. package/src/planner.ts +505 -0
  43. package/src/policy.test.ts +411 -0
  44. package/src/policy.ts +599 -0
  45. package/src/preflight.ts +348 -0
  46. package/src/prices.test.ts +69 -0
  47. package/src/prices.ts +90 -0
  48. package/src/provider.test.ts +366 -0
  49. package/src/provider.ts +502 -0
  50. package/src/secrets.test.ts +143 -0
  51. package/src/secrets.ts +66 -0
  52. package/src/server.ts +1992 -0
  53. package/src/services.test.ts +53 -0
  54. package/src/services.ts +102 -0
  55. package/src/sse-events.test.ts +83 -0
  56. package/src/store.test.ts +119 -0
  57. package/src/store.ts +346 -0
  58. package/src/tailscale.test.ts +32 -0
  59. package/src/tailscale.ts +79 -0
  60. package/src/title.ts +138 -0
  61. package/src/types.ts +442 -0
  62. package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
  63. package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
  64. package/ui/dist/favicon.svg +8 -0
  65. package/ui/dist/index.html +14 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Gateway supervisor tests.
3
+ *
4
+ * These drive a real child process against a stub upstream rather than mocking
5
+ * the spawn: the things that break here — a port that never binds, a gateway
6
+ * that dies, a credential that does or does not reach the upstream — are all
7
+ * properties of an actual process, and a mock would assert the design back at
8
+ * itself.
9
+ */
10
+ import test from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import http from 'node:http';
13
+ import {
14
+ ensureGateway, gatewayStatus, gatewayUsage, reapNow, releaseGateways, stopGateways,
15
+ } from './gateway.js';
16
+ import { providerEnv, resolveProvider } from './provider.js';
17
+
18
+ const ROOT = '/tmp/foreman-gateway-test';
19
+
20
+ /** A minimal OpenAI-compatible endpoint that records what it was sent. */
21
+ function stubUpstream(): Promise<{
22
+ url: string; seen: Array<{ auth?: string; body: any }>; close: () => Promise<void>;
23
+ }> {
24
+ const seen: Array<{ auth?: string; body: any }> = [];
25
+ const server = http.createServer((req, res) => {
26
+ const chunks: Buffer[] = [];
27
+ req.on('data', (c: Buffer) => chunks.push(c));
28
+ req.on('end', () => {
29
+ let body: any = null;
30
+ try { body = JSON.parse(Buffer.concat(chunks).toString()); } catch { /* not json */ }
31
+ seen.push({ auth: req.headers.authorization as string | undefined, body });
32
+ res.writeHead(200, { 'content-type': 'application/json' });
33
+ res.end(JSON.stringify({
34
+ id: 'chatcmpl-1', object: 'chat.completion', model: body?.model ?? 'stub',
35
+ choices: [{ index: 0, message: { role: 'assistant', content: 'pong' }, finish_reason: 'stop' }],
36
+ usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 },
37
+ }));
38
+ });
39
+ });
40
+ return new Promise((resolve) => {
41
+ server.listen(0, '127.0.0.1', () => {
42
+ const addr = server.address();
43
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
44
+ resolve({
45
+ url: `http://127.0.0.1:${port}`,
46
+ seen,
47
+ close: () => new Promise((r) => server.close(() => r())),
48
+ });
49
+ });
50
+ });
51
+ }
52
+
53
+ test('a gateway starts, translates, and carries the agent credential through', async (t) => {
54
+ const upstream = await stubUpstream();
55
+ t.after(async () => { stopGateways(); await upstream.close(); });
56
+
57
+ const provider = await resolveProvider(
58
+ { kind: 'openai-compatible', id: 'stub', baseUrl: upstream.url, apiKeyEnv: 'TEST_GW_KEY' },
59
+ ROOT,
60
+ );
61
+ process.env.TEST_GW_KEY = 'sk-stub-key';
62
+ const withKey = { ...(await resolveProvider(
63
+ { kind: 'openai-compatible', id: 'stub', baseUrl: upstream.url, apiKeyEnv: 'TEST_GW_KEY' }, ROOT)) };
64
+ assert.equal(withKey.problem, undefined);
65
+ assert.equal(provider.wire, 'gateway-openai');
66
+
67
+ const gatewayUrl = await ensureGateway(withKey);
68
+ assert.match(gatewayUrl, /^http:\/\/127\.0\.0\.1:\d+$/);
69
+ assert.notEqual(new URL(gatewayUrl).port, '11434', 'must not squat Ollama’s port');
70
+
71
+ // Speak to it the way the SDK does: Anthropic Messages in, x-api-key header.
72
+ const res = await fetch(`${gatewayUrl}/v1/messages`, {
73
+ method: 'POST',
74
+ headers: { 'content-type': 'application/json', 'x-api-key': 'sk-stub-key' },
75
+ body: JSON.stringify({
76
+ model: 'gpt-5', max_tokens: 16,
77
+ messages: [{ role: 'user', content: 'ping' }],
78
+ }),
79
+ });
80
+ assert.equal(res.status, 200);
81
+ const body = await res.json() as any;
82
+
83
+ // Out the far side it must look like an Anthropic response.
84
+ assert.equal(body.type, 'message');
85
+ assert.equal(body.role, 'assistant');
86
+ assert.equal(body.content[0].text, 'pong');
87
+
88
+ // And the upstream must have been spoken to in OpenAI's dialect, holding the
89
+ // credential the agent sent — not one the supervisor knows.
90
+ assert.equal(upstream.seen.length, 1);
91
+ assert.equal(upstream.seen[0].auth, 'Bearer sk-stub-key');
92
+ assert.equal(upstream.seen[0].body.messages[0].content, 'ping');
93
+ });
94
+
95
+ test('the same upstream is served by one process, a different one by another', async (t) => {
96
+ const a = await stubUpstream();
97
+ const b = await stubUpstream();
98
+ t.after(async () => { stopGateways(); await a.close(); await b.close(); });
99
+
100
+ const mk = (url: string, id: string) =>
101
+ resolveProvider({ kind: 'openai-compatible', id, baseUrl: url }, ROOT);
102
+
103
+ const first = await ensureGateway(await mk(a.url, 'a'));
104
+ const same = await ensureGateway(await mk(a.url, 'a2'));
105
+ const other = await ensureGateway(await mk(b.url, 'b'));
106
+
107
+ assert.equal(first, same, 'one upstream, one process');
108
+ assert.notEqual(first, other, 'two upstreams cannot share a process');
109
+ assert.equal(gatewayStatus().length, 2);
110
+ assert.ok(gatewayStatus().every((g) => !('apiKey' in g)), 'status must never carry a secret');
111
+ });
112
+
113
+ test('an agent env built for a gateway points at that gateway', async (t) => {
114
+ const upstream = await stubUpstream();
115
+ t.after(async () => { stopGateways(); await upstream.close(); });
116
+
117
+ const p = await resolveProvider(
118
+ { kind: 'openai-compatible', id: 'e2e', baseUrl: upstream.url, model: 'llama3' }, ROOT);
119
+ const url = await ensureGateway(p);
120
+ const { env } = providerEnv(p, url);
121
+ assert.equal(env?.ANTHROPIC_BASE_URL, url);
122
+ assert.ok(env?.ANTHROPIC_API_KEY, 'the invariant still holds through the supervisor');
123
+ assert.equal(env?.ANTHROPIC_DEFAULT_SONNET_MODEL, 'llama3');
124
+ });
125
+
126
+ test('a gateway a run still holds is never reaped, however quiet', async (t) => {
127
+ const upstream = await stubUpstream();
128
+ t.after(async () => { stopGateways(); await upstream.close(); });
129
+
130
+ const p = await resolveProvider(
131
+ { kind: 'openai-compatible', id: 'held', baseUrl: upstream.url }, ROOT);
132
+
133
+ // Idle time moves when an agent env is BUILT, which happens once at
134
+ // dispatch — not when requests flow. Reaping on that alone pulled the proxy
135
+ // out from under a thirty-minute mission ten minutes in, and every call
136
+ // after it failed with a refused connection.
137
+ const url = await ensureGateway(p, 'run-1');
138
+ assert.equal(gatewayStatus().length, 1);
139
+
140
+ reapNow(Date.now() + 60 * 60_000);
141
+ assert.equal(gatewayStatus().length, 1, 'held by run-1, so it must survive');
142
+
143
+ const stillThere = await fetch(`${url}/v1/messages`, {
144
+ method: 'POST',
145
+ headers: { 'content-type': 'application/json', 'x-api-key': 'k' },
146
+ body: JSON.stringify({ model: 'm', max_tokens: 4, messages: [{ role: 'user', content: 'hi' }] }),
147
+ });
148
+ assert.equal(stillThere.status, 200, 'a held gateway still answers');
149
+
150
+ releaseGateways('run-1');
151
+ reapNow(Date.now() + 60 * 60_000);
152
+ assert.equal(gatewayStatus().length, 0, 'released and idle, so it goes');
153
+ });
154
+
155
+ test('two runs holding one gateway: the first to finish does not kill it', async (t) => {
156
+ const upstream = await stubUpstream();
157
+ t.after(async () => { stopGateways(); await upstream.close(); });
158
+
159
+ const p = await resolveProvider(
160
+ { kind: 'openai-compatible', id: 'shared', baseUrl: upstream.url }, ROOT);
161
+ await ensureGateway(p, 'run-a');
162
+ await ensureGateway(p, 'run-b');
163
+
164
+ releaseGateways('run-a');
165
+ reapNow(Date.now() + 60 * 60_000);
166
+ assert.equal(gatewayStatus().length, 1, 'run-b still needs it');
167
+
168
+ releaseGateways('run-b');
169
+ reapNow(Date.now() + 60 * 60_000);
170
+ assert.equal(gatewayStatus().length, 0);
171
+ });
172
+
173
+ test('a run key on the path is stripped, attributed, and never sent upstream', async (t) => {
174
+ // The mechanism the whole ledger rests on: Foreman points an agent at
175
+ // `.../run/<key>`, the SDK preserves that prefix (measured — it sends
176
+ // `POST /run/<key>/v1/messages`), and the gateway must both count against
177
+ // the key AND hand the upstream the path it expects.
178
+ const upstream = await stubUpstream();
179
+ t.after(async () => { stopGateways(); await upstream.close(); });
180
+
181
+ const p = await resolveProvider(
182
+ { kind: 'openai-compatible', id: 'ledger', baseUrl: upstream.url }, ROOT);
183
+ const url = await ensureGateway(p, 'run-x');
184
+
185
+ const ask = (path: string) => fetch(`${url}${path}/v1/messages`, {
186
+ method: 'POST',
187
+ headers: { 'content-type': 'application/json', 'x-api-key': 'k' },
188
+ body: JSON.stringify({ model: 'm', max_tokens: 8, messages: [{ role: 'user', content: 'hi' }] }),
189
+ });
190
+
191
+ const res = await ask('/run/mission-1.0');
192
+ assert.equal(res.status, 200, 'a keyed request is served exactly like an unkeyed one');
193
+ const body = await res.json() as { content: Array<{ text: string }> };
194
+ assert.equal(body.content[0].text, 'pong', 'the agent must not be able to tell it was counted');
195
+
196
+ // The upstream sees its own API, not Foreman's routing.
197
+ assert.equal(upstream.seen.length, 1);
198
+
199
+ const totals = await gatewayUsage('mission-1.0');
200
+ assert.ok(totals, 'the key should have been attributed');
201
+ assert.equal(totals!.inputTokens, 3, 'the stub reports 3 prompt tokens');
202
+ assert.equal(totals!.outputTokens, 1);
203
+ assert.equal(totals!.calls, 1);
204
+
205
+ // A different attempt of the same run is a different bucket.
206
+ assert.equal(await gatewayUsage('mission-1.1'), null);
207
+ });
208
+
209
+ test('an unkeyed request is still served, just not counted', async (t) => {
210
+ // A gateway that refused traffic it could not label would turn bookkeeping
211
+ // into an outage.
212
+ const upstream = await stubUpstream();
213
+ t.after(async () => { stopGateways(); await upstream.close(); });
214
+
215
+ const p = await resolveProvider(
216
+ { kind: 'openai-compatible', id: 'unkeyed', baseUrl: upstream.url }, ROOT);
217
+ const url = await ensureGateway(p, 'run-y');
218
+
219
+ const res = await fetch(`${url}/v1/messages`, {
220
+ method: 'POST',
221
+ headers: { 'content-type': 'application/json', 'x-api-key': 'k' },
222
+ body: JSON.stringify({ model: 'm', max_tokens: 8, messages: [{ role: 'user', content: 'hi' }] }),
223
+ });
224
+ assert.equal(res.status, 200);
225
+ assert.equal((await res.json() as any).content[0].text, 'pong');
226
+ });
package/src/gateway.ts ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Gateway supervisor — one translating proxy per active provider.
3
+ *
4
+ * `src/gateway/llm-gateway.cjs` is a verbatim copy from a sibling project. It
5
+ * takes its upstream and mode from process env and serves one of them, which
6
+ * is right for a container holding a single agent and wrong for Foreman, where
7
+ * one server may run missions for several projects on different providers at
8
+ * once.
9
+ *
10
+ * The answer is a process per provider rather than a router inside the file.
11
+ * That keeps the copy verbatim — its test suite applies unchanged and
12
+ * re-syncing upstream stays a `cp` — and it buys process isolation and a
13
+ * lifecycle that ends when a provider goes idle. See
14
+ * docs/provider-model-tracker.md, Decisions.
15
+ *
16
+ * Nothing here ever sees a credential. The gateway authenticates per request
17
+ * from the header the agent sends, so a running gateway is a route, not a
18
+ * secret: the key travels agent → gateway → upstream and is never in this
19
+ * process's argv, env, or logs.
20
+ */
21
+ import { spawn, type ChildProcess } from 'node:child_process';
22
+ import net from 'node:net';
23
+ import { fileURLToPath } from 'node:url';
24
+ import type { ResolvedProvider } from './provider.js';
25
+
26
+ // ledger.cjs, not llm-gateway.cjs: the wrapper binds the socket, counts tokens
27
+ // per run, and hands every request to the ported handler unchanged. See its
28
+ // header for why the counting lives outside the file it wraps.
29
+ const GATEWAY_CLI = fileURLToPath(new URL('./gateway/ledger.cjs', import.meta.url));
30
+
31
+ /** Give up on a gateway that has not bound its port by then. */
32
+ const START_TIMEOUT_MS = 10_000;
33
+
34
+ /**
35
+ * A gateway held by nobody for this long is shut down. Restarting one costs
36
+ * ~200ms, so reaping is cheap — but only once nothing can still need it.
37
+ */
38
+ const IDLE_MS = 10 * 60_000;
39
+
40
+ /** Restarts within this window count toward the crash-loop cutoff. */
41
+ const CRASH_WINDOW_MS = 60_000;
42
+ const MAX_RESTARTS_PER_WINDOW = 3;
43
+
44
+ interface Gateway {
45
+ key: string;
46
+ port: number;
47
+ mode: 'openai' | 'codex';
48
+ upstream: string;
49
+ child: ChildProcess;
50
+ /** Bumped whenever an agent env is built against this gateway. */
51
+ lastUsed: number;
52
+ /**
53
+ * Runs currently depending on this gateway.
54
+ *
55
+ * Idle time alone is not safe to reap on: `lastUsed` moves when an agent
56
+ * environment is *built*, which happens once at dispatch, not when requests
57
+ * flow. A mission that ran for thirty minutes therefore looked idle after
58
+ * ten, and the proxy was pulled out from under a live run — every subsequent
59
+ * call failed with a refused connection, for the director and its workers
60
+ * alike. A gateway with holders is never reaped, however quiet it looks.
61
+ */
62
+ holders: Set<string>;
63
+ restarts: number[];
64
+ /** Set when the gateway has crash-looped; reported instead of restarted. */
65
+ broken?: string;
66
+ }
67
+
68
+ const running = new Map<string, Gateway>();
69
+ let reaper: NodeJS.Timeout | null = null;
70
+
71
+ /**
72
+ * Identity of a gateway. Mode and upstream are all that distinguish one from
73
+ * another — two providers pointing at the same endpoint in the same mode can
74
+ * share, since the credential rides on each request rather than the process.
75
+ */
76
+ function keyFor(p: ResolvedProvider): string {
77
+ // Account id is part of the identity for Codex: it is the gateway's fallback
78
+ // when a token carries no account claim, and two logins sharing a process
79
+ // would then borrow the first one's.
80
+ return `${p.wire}|${p.upstreamUrl ?? ''}|${p.accountId ?? ''}`;
81
+ }
82
+
83
+ /** An unused loopback port, chosen by the OS rather than guessed. */
84
+ function freePort(): Promise<number> {
85
+ return new Promise((resolve, reject) => {
86
+ const srv = net.createServer();
87
+ srv.on('error', reject);
88
+ srv.listen(0, '127.0.0.1', () => {
89
+ const addr = srv.address();
90
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
91
+ srv.close(() => (port ? resolve(port) : reject(new Error('no free port'))));
92
+ });
93
+ });
94
+ }
95
+
96
+ /** Resolves once something is listening on the port, or rejects on timeout. */
97
+ async function waitForListen(port: number, child: ChildProcess): Promise<void> {
98
+ const deadline = Date.now() + START_TIMEOUT_MS;
99
+ for (;;) {
100
+ if (child.exitCode !== null || child.signalCode) {
101
+ throw new Error(`gateway exited during startup (code ${child.exitCode ?? child.signalCode})`);
102
+ }
103
+ const up = await new Promise<boolean>((resolve) => {
104
+ const sock = net.connect({ port, host: '127.0.0.1' });
105
+ const done = (ok: boolean) => { sock.destroy(); resolve(ok); };
106
+ sock.once('connect', () => done(true));
107
+ sock.once('error', () => done(false));
108
+ sock.setTimeout(500, () => done(false));
109
+ });
110
+ if (up) return;
111
+ if (Date.now() > deadline) throw new Error(`gateway did not listen on ${port} within ${START_TIMEOUT_MS}ms`);
112
+ await new Promise((r) => setTimeout(r, 50));
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Ensures a gateway is running for this provider and returns its base URL.
118
+ *
119
+ * Called on the dispatch path, so it throws rather than returning a broken
120
+ * URL: a run that cannot reach its gateway must fail with a reason, not send
121
+ * requests into a closed port.
122
+ */
123
+ export async function ensureGateway(p: ResolvedProvider, holder?: string): Promise<string> {
124
+ if (p.wire === 'anthropic-native') throw new Error('native providers need no gateway');
125
+ const key = keyFor(p);
126
+
127
+ const existing = running.get(key);
128
+ if (existing) {
129
+ if (existing.broken) throw new Error(existing.broken);
130
+ if (existing.child.exitCode === null) {
131
+ existing.lastUsed = Date.now();
132
+ if (holder) existing.holders.add(holder);
133
+ return `http://127.0.0.1:${existing.port}`;
134
+ }
135
+ running.delete(key);
136
+ }
137
+
138
+ const mode = p.wire === 'gateway-codex' ? 'codex' as const : 'openai' as const;
139
+ const upstream = p.upstreamUrl ?? '';
140
+ const accountId = p.accountId ?? '';
141
+ const port = await freePort();
142
+
143
+ // Only routing goes in the env. LLM_GATEWAY_DEFAULT_MODEL is deliberately
144
+ // absent: the model-alias remap already happens in the agent's own env, and
145
+ // baking a model into a shared process would make it wrong for the next
146
+ // provider that reuses this gateway.
147
+ const child = spawn(process.execPath, [GATEWAY_CLI], {
148
+ env: {
149
+ ...process.env,
150
+ LLM_GATEWAY_MODE: mode,
151
+ LLM_GATEWAY_TARGET_URL: upstream,
152
+ LLM_GATEWAY_PORT: String(port),
153
+ // The ported file defaults this to the name of the project it came from.
154
+ // Foreman says who it actually is: the originator header is a claim
155
+ // about which client is calling, and sending someone else's name — or
156
+ // the CLI's, to look like Codex itself — would be a lie told to a
157
+ // vendor. See provider-model.md §5.
158
+ CODEX_ORIGINATOR: process.env.CODEX_ORIGINATOR || 'foreman',
159
+ // Only consulted when the token carries no account claim of its own.
160
+ ...(accountId ? { CODEX_ACCOUNT_ID: accountId } : {}),
161
+ // Opt-in request dumping for diagnosing a stall that only the real
162
+ // payload reproduces. Forwarded only when the operator set it: the
163
+ // files contain prompts and repository contents.
164
+ ...(process.env.FOREMAN_GATEWAY_DUMP_DIR
165
+ ? { FOREMAN_GATEWAY_DUMP_DIR: process.env.FOREMAN_GATEWAY_DUMP_DIR } : {}),
166
+ },
167
+ stdio: ['ignore', 'pipe', 'pipe'],
168
+ });
169
+ child.unref();
170
+
171
+ const gw: Gateway = {
172
+ key, port, mode, upstream, child, lastUsed: Date.now(), restarts: [],
173
+ holders: new Set(holder ? [holder] : []),
174
+ };
175
+ running.set(key, gw);
176
+
177
+ // The gateway logs one line per translated model substitution and its own
178
+ // startup banner. Prefix them so they are attributable in Foreman's output.
179
+ const log = (buf: Buffer) => {
180
+ for (const line of buf.toString().split('\n')) {
181
+ if (line.trim()) console.error(`[gateway ${mode}:${port}] ${line}`);
182
+ }
183
+ };
184
+ child.stdout?.on('data', log);
185
+ child.stderr?.on('data', log);
186
+
187
+ child.on('exit', (code, signal) => {
188
+ const g = running.get(key);
189
+ if (!g || g.child !== child) return; // already replaced
190
+ const now = Date.now();
191
+ g.restarts = [...g.restarts.filter((t) => now - t < CRASH_WINDOW_MS), now];
192
+ if (g.restarts.length > MAX_RESTARTS_PER_WINDOW) {
193
+ // A gateway that dies repeatedly is misconfigured, not unlucky. Report it
194
+ // instead of restarting forever behind a mission that keeps failing.
195
+ g.broken = `gateway for ${upstream || mode} keeps exiting (last: code ${code ?? signal})`;
196
+ console.error(`[gateway] ${g.broken}`);
197
+ return;
198
+ }
199
+ running.delete(key);
200
+ });
201
+
202
+ try {
203
+ await waitForListen(port, child);
204
+ } catch (err) {
205
+ child.kill();
206
+ running.delete(key);
207
+ throw new Error(`could not start the gateway: ${String(err instanceof Error ? err.message : err)}`);
208
+ }
209
+
210
+ startReaper();
211
+ return `http://127.0.0.1:${port}`;
212
+ }
213
+
214
+ /**
215
+ * Retires gateways nothing holds and nothing has used recently.
216
+ *
217
+ * Exported so a test can run it at an arbitrary clock instead of waiting a
218
+ * real ten minutes — the property worth testing is *which* gateways it spares,
219
+ * and that is invisible on the interval's own schedule.
220
+ */
221
+ export function reapNow(now = Date.now()): void {
222
+ for (const [key, g] of running) {
223
+ if (g.holders.size > 0) continue;
224
+ if (now - g.lastUsed < IDLE_MS) continue;
225
+ g.child.kill();
226
+ running.delete(key);
227
+ }
228
+ }
229
+
230
+ /** Stops gateways nothing has used recently. Cheap to restart on demand. */
231
+ function startReaper(): void {
232
+ if (reaper) return;
233
+ reaper = setInterval(() => {
234
+ reapNow();
235
+ if (running.size === 0 && reaper) {
236
+ clearInterval(reaper);
237
+ reaper = null;
238
+ }
239
+ }, 60_000);
240
+ reaper.unref();
241
+ }
242
+
243
+ /**
244
+ * Releases every gateway a run was holding. Called when the run ends, however
245
+ * it ends — the reaper can then retire anything nothing else needs.
246
+ */
247
+ export function releaseGateways(holder: string): void {
248
+ for (const g of running.values()) {
249
+ if (g.holders.delete(holder)) g.lastUsed = Date.now();
250
+ }
251
+ }
252
+
253
+ /** Stops every gateway. Called on server shutdown so none are orphaned. */
254
+ export function stopGateways(): void {
255
+ for (const [key, g] of running) {
256
+ g.child.kill();
257
+ running.delete(key);
258
+ }
259
+ if (reaper) {
260
+ clearInterval(reaper);
261
+ reaper = null;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Token totals every running gateway has counted for one run key.
267
+ *
268
+ * Summed across gateways because a run may hold two — a director on one
269
+ * provider, workers on another — and the caller wants the run's total, not a
270
+ * per-process breakdown. A gateway that has seen nothing for this key simply
271
+ * contributes nothing, so an unreachable or restarted one degrades to a lower
272
+ * number rather than an error: this is a live read of work in progress, and
273
+ * the SDK's own figure at the end of the turn remains the authority.
274
+ */
275
+ export async function gatewayUsage(key: string): Promise<{
276
+ inputTokens: number; outputTokens: number; cacheReadTokens: number;
277
+ cacheWriteTokens: number; calls: number; costUsd?: number;
278
+ } | null> {
279
+ let out: {
280
+ inputTokens: number; outputTokens: number; cacheReadTokens: number;
281
+ cacheWriteTokens: number; calls: number; costUsd?: number;
282
+ } | null = null;
283
+ for (const g of running.values()) {
284
+ if (g.broken) continue;
285
+ const body = await fetch(`http://127.0.0.1:${g.port}/_foreman/usage`, {
286
+ signal: AbortSignal.timeout(1500),
287
+ }).then((r) => (r.ok ? r.json() : null)).catch(() => null) as Record<string, {
288
+ inputTokens: number; outputTokens: number; cacheReadTokens: number;
289
+ cacheWriteTokens: number; calls: number; costUsd?: number;
290
+ }> | null;
291
+ const t = body?.[key];
292
+ if (!t) continue;
293
+ out ??= { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, calls: 0 };
294
+ out.inputTokens += t.inputTokens;
295
+ out.outputTokens += t.outputTokens;
296
+ out.cacheReadTokens += t.cacheReadTokens;
297
+ out.cacheWriteTokens += t.cacheWriteTokens;
298
+ out.calls += t.calls;
299
+ if (typeof t.costUsd === 'number') out.costUsd = (out.costUsd ?? 0) + t.costUsd;
300
+ }
301
+ return out;
302
+ }
303
+
304
+ /** Running gateways, for preflight and diagnostics. Never includes secrets. */
305
+ export function gatewayStatus(): Array<{ mode: string; upstream: string; port: number; broken?: string }> {
306
+ return [...running.values()].map((g) => ({
307
+ mode: g.mode, upstream: g.upstream, port: g.port, broken: g.broken,
308
+ }));
309
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Claude Code installs — discovery and description.
3
+ *
4
+ * A machine can host several side by side: distinct CLAUDE_CONFIG_DIRs (each
5
+ * with its own credentials, settings and plugins) and potentially distinct
6
+ * executables. This module finds them and describes them.
7
+ *
8
+ * It deliberately does NOT decide what an agent authenticates as. That is one
9
+ * question with one answer, and it lives in provider.ts — a Claude Code
10
+ * install is only one of several things a provider can be, and having two
11
+ * modules able to build an agent's environment is how a credential ends up
12
+ * somewhere it should not.
13
+ */
14
+ import os from 'node:os';
15
+ import { readdir, stat } from 'node:fs/promises';
16
+ import path from 'node:path';
17
+
18
+ export interface ClaudeInstance {
19
+ /** CLAUDE_CONFIG_DIR for the agent — selects credentials, settings, plugins. */
20
+ configDir?: string;
21
+ /** Claude Code executable; the SDK's bundled one is used when absent. */
22
+ executable?: string;
23
+ }
24
+
25
+ /** Expands a leading `~` so config can be written the way people type it. */
26
+ function expandHome(p: string): string {
27
+ if (p === '~') return os.homedir();
28
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
29
+ return p;
30
+ }
31
+
32
+ function clean(value: string | undefined): string | undefined {
33
+ const trimmed = value?.trim();
34
+ return trimmed ? expandHome(trimmed) : undefined;
35
+ }
36
+
37
+ /** Server-wide default, from the environment. */
38
+ export function defaultInstance(): ClaudeInstance {
39
+ return {
40
+ configDir: clean(process.env.FOREMAN_CLAUDE_CONFIG_DIR),
41
+ executable: clean(process.env.FOREMAN_CLAUDE_EXECUTABLE),
42
+ };
43
+ }
44
+
45
+ /**
46
+ * One-line description for the preflight screen and run history.
47
+ *
48
+ * Resolves through effectiveConfigDir rather than assuming ~/.claude, because a
49
+ * CLAUDE_CONFIG_DIR inherited from the launching shell silently selects a
50
+ * different account — and printing the wrong directory here is worse than
51
+ * printing nothing, since this line is what someone checks before spending.
52
+ */
53
+ export function describeInstance(instance: ClaudeInstance): string {
54
+ const dir = effectiveConfigDir(instance);
55
+ const inherited = !instance.configDir ? ' (inherited)' : '';
56
+ return [
57
+ `${dir}${inherited}`,
58
+ instance.executable ? path.basename(instance.executable) : 'bundled executable',
59
+ ].join(' · ');
60
+ }
61
+
62
+ /** One Claude Code install offered in the project settings picker. */
63
+ export interface DiscoveredInstance {
64
+ /** Absolute config dir (CLAUDE_CONFIG_DIR). */
65
+ configDir: string;
66
+ /** How it was found, for the muted hint in the picker. */
67
+ origin: 'server default' | 'this process' | 'found on disk';
68
+ /** Whether that dir holds a stored subscription login. */
69
+ hasStoredLogin: boolean;
70
+ }
71
+
72
+ /**
73
+ * Config dirs worth offering: the server default, whatever this process was
74
+ * started with, and any `~/.claude*` directory that actually looks like a
75
+ * Claude Code install. Free-text entry stays available for anything missed.
76
+ */
77
+ export async function discoverInstances(
78
+ probe: (dir: string) => Promise<boolean>,
79
+ ): Promise<DiscoveredInstance[]> {
80
+ const home = os.homedir();
81
+ const origins = new Map<string, DiscoveredInstance['origin']>();
82
+
83
+ const note = (dir: string | undefined, origin: DiscoveredInstance['origin']) => {
84
+ const resolved = clean(dir);
85
+ if (resolved && !origins.has(resolved)) origins.set(resolved, origin);
86
+ };
87
+
88
+ note(process.env.FOREMAN_CLAUDE_CONFIG_DIR, 'server default');
89
+ note(process.env.CLAUDE_CONFIG_DIR, 'this process');
90
+ note(path.join(home, '.claude'), 'found on disk');
91
+
92
+ const entries = await readdir(home, { withFileTypes: true }).catch(() => []);
93
+ for (const e of entries) {
94
+ if (!e.isDirectory() || !e.name.startsWith('.claude')) continue;
95
+ note(path.join(home, e.name), 'found on disk');
96
+ }
97
+
98
+ const found = await Promise.all(
99
+ [...origins].map(async ([configDir, origin]) => {
100
+ const hasStoredLogin = await probe(configDir);
101
+ // A dir only counts as an install if it holds a login or real settings.
102
+ const looksReal =
103
+ hasStoredLogin || (await stat(path.join(configDir, 'settings.json')).then(() => true, () => false));
104
+ return looksReal || origin !== 'found on disk'
105
+ ? { configDir, origin, hasStoredLogin }
106
+ : null;
107
+ }),
108
+ );
109
+
110
+ return found.filter((i): i is DiscoveredInstance => i !== null)
111
+ .sort((a, b) => a.configDir.localeCompare(b.configDir));
112
+ }
113
+
114
+ /**
115
+ * The config dir that will actually apply to an agent — the pin if there is
116
+ * one, otherwise whatever the server process itself resolves to.
117
+ */
118
+ export function effectiveConfigDir(instance: ClaudeInstance): string {
119
+ return (
120
+ instance.configDir ??
121
+ clean(process.env.CLAUDE_CONFIG_DIR) ??
122
+ path.join(os.homedir(), '.claude')
123
+ );
124
+ }