@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.
- package/DESIGN.md +408 -0
- package/LICENSE +15 -0
- package/README.md +133 -0
- package/bin/foreman.mjs +58 -0
- package/package.json +68 -0
- package/scripts/prepare.mjs +48 -0
- package/skills/director/SKILL.md +65 -0
- package/src/anthropic-models.ts +54 -0
- package/src/ask.test.ts +88 -0
- package/src/ask.ts +95 -0
- package/src/attachments.test.ts +33 -0
- package/src/attachments.ts +60 -0
- package/src/cli.test.ts +27 -0
- package/src/cli.ts +297 -0
- package/src/codex.test.ts +328 -0
- package/src/codex.ts +196 -0
- package/src/cost-basis.test.ts +76 -0
- package/src/deck.test.ts +402 -0
- package/src/deck.ts +892 -0
- package/src/fork.test.ts +31 -0
- package/src/gateway/ledger.cjs +326 -0
- package/src/gateway/ledger.test.ts +255 -0
- package/src/gateway/llm-gateway.cjs +1411 -0
- package/src/gateway/llm-gateway.test.ts +478 -0
- package/src/gateway.test.ts +226 -0
- package/src/gateway.ts +309 -0
- package/src/instance.ts +124 -0
- package/src/models.test.ts +147 -0
- package/src/models.ts +158 -0
- package/src/notify/commands.test.ts +28 -0
- package/src/notify/commands.ts +73 -0
- package/src/notify/telegram.ts +259 -0
- package/src/notify.test.ts +343 -0
- package/src/notify.ts +495 -0
- package/src/ollama.test.ts +49 -0
- package/src/ollama.ts +49 -0
- package/src/openai-prices.test.ts +58 -0
- package/src/openai-prices.ts +106 -0
- package/src/orchestrator.test.ts +1147 -0
- package/src/orchestrator.ts +2325 -0
- package/src/planner.test.ts +60 -0
- package/src/planner.ts +505 -0
- package/src/policy.test.ts +411 -0
- package/src/policy.ts +599 -0
- package/src/preflight.ts +348 -0
- package/src/prices.test.ts +69 -0
- package/src/prices.ts +90 -0
- package/src/provider.test.ts +366 -0
- package/src/provider.ts +502 -0
- package/src/secrets.test.ts +143 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +1992 -0
- package/src/services.test.ts +53 -0
- package/src/services.ts +102 -0
- package/src/sse-events.test.ts +83 -0
- package/src/store.test.ts +119 -0
- package/src/store.ts +346 -0
- package/src/tailscale.test.ts +32 -0
- package/src/tailscale.ts +79 -0
- package/src/title.ts +138 -0
- package/src/types.ts +442 -0
- package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
- package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
- package/ui/dist/favicon.svg +8 -0
- package/ui/dist/index.html +14 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,1992 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foreman HTTP server — thin wiring between the browser UI, mission
|
|
3
|
+
* orchestrators, and the run store. No business logic lives here.
|
|
4
|
+
*
|
|
5
|
+
* Concurrency model: many projects may each have at most ONE active mission;
|
|
6
|
+
* missions across projects run concurrently. Every live SSE frame is wrapped
|
|
7
|
+
* in an envelope `{runId, projectId, data}` so the UI can route it; persisted
|
|
8
|
+
* event logs keep the bare `{ts, event, data}` shape (the run is implicit in
|
|
9
|
+
* the file's location).
|
|
10
|
+
*
|
|
11
|
+
* Endpoints:
|
|
12
|
+
* GET / React app (ui/dist; build hint when missing)
|
|
13
|
+
* GET /favicon.svg Brand mark from ui/public
|
|
14
|
+
* GET /events SSE stream (enveloped ForemanEvents)
|
|
15
|
+
* GET /settings Persisted UI settings (global + project overlays)
|
|
16
|
+
* PUT /settings Save settings {global, projectId?, project?}
|
|
17
|
+
* GET /models Curated model list for the composer pickers
|
|
18
|
+
* GET /projects Projects + active-run summaries + pending counts + lastRun
|
|
19
|
+
* POST /projects Link a folder {folder, name?}
|
|
20
|
+
* DELETE /projects/{id} Unlink (history kept; active run blocks it)
|
|
21
|
+
* POST /run Start a mission {projectId, mission, budgetUsd,
|
|
22
|
+
* directorModel?, workerModel?, browserTools?}
|
|
23
|
+
* PATCH /run Change a live run's browser tools or budget
|
|
24
|
+
* POST /runs/{id}/resume Resume an interrupted/failed run
|
|
25
|
+
* POST /permission Resolve an approval {id, behavior, message?}
|
|
26
|
+
* POST /answer Answer a director question {id, text}
|
|
27
|
+
* POST /steer Send an operator note to a running director {runId, text}
|
|
28
|
+
* POST /interrupt Interrupt a run {runId}
|
|
29
|
+
* GET /runs?projectId= Persisted run summaries, newest first
|
|
30
|
+
* GET /runs/{id}/events Full event log for replay
|
|
31
|
+
* GET /missiondoc?run= A run's .foreman/MISSION.md
|
|
32
|
+
* GET /browse?path= Directory listing for the folder picker
|
|
33
|
+
* POST /mkdir Create a subfolder {parent, name}
|
|
34
|
+
* GET /locate?name= Find folders by name under $HOME (drag-drop)
|
|
35
|
+
* GET /chat?projectId= A project's planning conversation (log + meta)
|
|
36
|
+
* POST /chat Send a message to the planner {projectId, text}
|
|
37
|
+
* POST /attachments Save files into <folder>/.foreman/attachments {projectId, files:[{name,data}]}
|
|
38
|
+
* POST /chat/stop Stop the planner's reply in flight {projectId}
|
|
39
|
+
* DELETE /chat?projectId= Forget the conversation and its session
|
|
40
|
+
* PUT /providers/{id}/key Store a provider's key {key}
|
|
41
|
+
* DELETE /providers/{id}/key Forget it
|
|
42
|
+
*/
|
|
43
|
+
import http from 'node:http';
|
|
44
|
+
import crypto from 'node:crypto';
|
|
45
|
+
import os from 'node:os';
|
|
46
|
+
import { mkdir, readFile, readdir, stat } from 'node:fs/promises';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
import { fileURLToPath } from 'node:url';
|
|
49
|
+
import { MissionRun } from './orchestrator.js';
|
|
50
|
+
import {
|
|
51
|
+
DEFAULT_PLANNER_MODEL, answerChatQuestion, pendingChatQuestion, runPlanningTurn,
|
|
52
|
+
forkSeed,
|
|
53
|
+
dropPendingAsk,
|
|
54
|
+
} from './planner.js';
|
|
55
|
+
import { DEFAULT_TOOL_POLICY } from './policy.js';
|
|
56
|
+
import { saveAttachments } from './attachments.js';
|
|
57
|
+
import { detectTailscale, tailnetUrl } from './tailscale.js';
|
|
58
|
+
import { ServiceRegistry, SVC_PREFIX, parseServicePath, portOpen, proxyToService, servicePath } from './services.js';
|
|
59
|
+
import { HELP_TEXT, parseCommand, projectsRoot, slug } from './notify/commands.js';
|
|
60
|
+
import { escapeHtml as escTg } from './notify.js';
|
|
61
|
+
import { RunStore, newRunId } from './store.js';
|
|
62
|
+
import { preflight, reportPreflight } from './preflight.js';
|
|
63
|
+
import { defaultInstance, discoverInstances, effectiveConfigDir } from './instance.js';
|
|
64
|
+
import {
|
|
65
|
+
normalizeOpenAiBaseUrl, providerEnv, providerOf, providerProblem, resolveProvider, roleCost,
|
|
66
|
+
withRoleModel,
|
|
67
|
+
} from './provider.js';
|
|
68
|
+
import { ensureGateway, gatewayStatus, gatewayUsage, releaseGateways, stopGateways } from './gateway.js';
|
|
69
|
+
import { discoverOllama, ollamaHost, ollamaProvider } from './ollama.js';
|
|
70
|
+
import { deleteSecret, getSecret, hasSecret, putSecret } from './secrets.js';
|
|
71
|
+
import { NotifyHub } from './notify.js';
|
|
72
|
+
import { handleDeckRoute } from './deck.js';
|
|
73
|
+
import { TelegramBot, getMe, linkCode, telegramStartLink, telegramTransport, setBotCommands } from './notify/telegram.js';
|
|
74
|
+
import QRCode from 'qrcode';
|
|
75
|
+
import { ANTHROPIC_MODELS } from './anthropic-models.js';
|
|
76
|
+
import { codexHome, codexModels, readCodexAuth } from './codex.js';
|
|
77
|
+
import { costRank, describeModel, discoverModels } from './models.js';
|
|
78
|
+
import { isOpenAiHost, openaiPrice, openaiPriceNote } from './openai-prices.js';
|
|
79
|
+
import type { ResolvedProvider } from './provider.js';
|
|
80
|
+
import type { ModelPrice } from './prices.js';
|
|
81
|
+
import {
|
|
82
|
+
dirHasCredentials, detectAuth, hasKeychainCredentials, readAccount, type AuthMode,
|
|
83
|
+
} from './preflight.js';
|
|
84
|
+
import { combineBasis, costBasisOf } from './types.js';
|
|
85
|
+
import type {
|
|
86
|
+
ChatMeta, CostBasis, ForemanEvent, ModelChoice, Project, ProviderRef, RunMeta, ToolPolicy,
|
|
87
|
+
} from './types.js';
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Curated model list for the composer pickers (GET /models). `id` is exactly
|
|
91
|
+
* what the SDK receives as `options.model`.
|
|
92
|
+
*/
|
|
93
|
+
const MODELS = ANTHROPIC_MODELS;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The billing mode one project will actually use. `own-login` means the pinned
|
|
97
|
+
* config dir's stored login pays, so a dir without one is a misconfiguration
|
|
98
|
+
* worth surfacing before a mission starts rather than after it fails.
|
|
99
|
+
*/
|
|
100
|
+
async function projectBilling(p: Project, serverMode: AuthMode): Promise<BillingMode> {
|
|
101
|
+
const resolved = await resolveProvider(providerOf(p), store.root);
|
|
102
|
+
// A gateway provider bills its own upstream, never the server's Anthropic
|
|
103
|
+
// credential — reporting the server's mode there would name the wrong payer.
|
|
104
|
+
if (resolved.wire !== 'anthropic-native') {
|
|
105
|
+
if (!resolved.apiKey) return 'none';
|
|
106
|
+
// Loopback means the model is served from this machine: no per-token cost,
|
|
107
|
+
// which is why this run's budget caps on turns and time instead.
|
|
108
|
+
return isLoopback(resolved.upstreamUrl) ? 'local' : 'provider';
|
|
109
|
+
}
|
|
110
|
+
if (resolved.kind === 'anthropic-api') return resolved.apiKey ? 'api-key' : 'none';
|
|
111
|
+
if (!resolved.ownLogin) return serverMode;
|
|
112
|
+
return (await dirHasCredentials(resolved.configDir)) ? 'subscription' : 'none';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Fleet order. Insertion order answers "when did I link this", which is the
|
|
117
|
+
* one question nobody asks; these cards are a control surface, so the order
|
|
118
|
+
* is urgency, then liveness, then recency:
|
|
119
|
+
*
|
|
120
|
+
* 1. projects with an agent blocked on a human (approval or question)
|
|
121
|
+
* 2. projects with a mission running
|
|
122
|
+
* 3. everything else, most recently active first
|
|
123
|
+
*
|
|
124
|
+
* Within a tier the sort is by last activity too, so a card only moves when
|
|
125
|
+
* its state actually changed — a project does not drift under the cursor.
|
|
126
|
+
*/
|
|
127
|
+
function fleetTier(c: { pendingPermissions: number; pendingQuestions: number; activeRun: unknown }): number {
|
|
128
|
+
if (c.pendingPermissions + c.pendingQuestions > 0) return 0;
|
|
129
|
+
return c.activeRun ? 1 : 2;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function fleetOrder(
|
|
133
|
+
a: { pendingPermissions: number; pendingQuestions: number; activeRun: unknown; lastActivityAt: number },
|
|
134
|
+
b: { pendingPermissions: number; pendingQuestions: number; activeRun: unknown; lastActivityAt: number },
|
|
135
|
+
): number {
|
|
136
|
+
return fleetTier(a) - fleetTier(b) || b.lastActivityAt - a.lastActivityAt;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parses a provider from a request body.
|
|
141
|
+
*
|
|
142
|
+
* The union is the product's safety property — "subscription login" and
|
|
143
|
+
* "custom base URL" must not be expressible together — so it is validated
|
|
144
|
+
* here, at the boundary, rather than trusted from a client. Anything
|
|
145
|
+
* unrecognised is rejected outright: silently coercing a malformed provider to
|
|
146
|
+
* `claude-code` would run a mission on a credential nobody chose.
|
|
147
|
+
*
|
|
148
|
+
* Returns `null` for "not supplied" and a string for "supplied but wrong".
|
|
149
|
+
*/
|
|
150
|
+
function parseProvider(v: unknown): ProviderRef | null | string {
|
|
151
|
+
if (v === undefined) return null;
|
|
152
|
+
if (v === null) return null; // an explicit clear; the caller distinguishes
|
|
153
|
+
if (typeof v !== 'object') return 'provider must be an object';
|
|
154
|
+
const p = v as Record<string, unknown>;
|
|
155
|
+
const str = (k: string): string | undefined => {
|
|
156
|
+
const raw = p[k];
|
|
157
|
+
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
switch (p.kind) {
|
|
161
|
+
case 'claude-code':
|
|
162
|
+
return {
|
|
163
|
+
kind: 'claude-code',
|
|
164
|
+
...(str('configDir') ? { configDir: str('configDir')! } : {}),
|
|
165
|
+
...(str('executable') ? { executable: str('executable')! } : {}),
|
|
166
|
+
...(p.ownLogin === true ? { ownLogin: true } : {}),
|
|
167
|
+
};
|
|
168
|
+
case 'anthropic-api': {
|
|
169
|
+
const apiKeyEnv = str('apiKeyEnv');
|
|
170
|
+
if (!apiKeyEnv) return 'anthropic-api needs apiKeyEnv';
|
|
171
|
+
return { kind: 'anthropic-api', id: str('id') ?? newProviderId(), apiKeyEnv, model: str('model') };
|
|
172
|
+
}
|
|
173
|
+
case 'codex':
|
|
174
|
+
return {
|
|
175
|
+
kind: 'codex', id: str('id') ?? newProviderId(),
|
|
176
|
+
codexHome: str('codexHome'), upstreamUrl: str('upstreamUrl'), model: str('model'),
|
|
177
|
+
};
|
|
178
|
+
case 'openai-compatible': {
|
|
179
|
+
const baseUrl = str('baseUrl');
|
|
180
|
+
if (!baseUrl) return 'openai-compatible needs baseUrl';
|
|
181
|
+
// Reject a URL the gateway would choke on before it reaches a run,
|
|
182
|
+
// rather than after the agent's first call fails.
|
|
183
|
+
try {
|
|
184
|
+
new URL(normalizeOpenAiBaseUrl(baseUrl));
|
|
185
|
+
} catch {
|
|
186
|
+
return `not a usable base URL: ${baseUrl}`;
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
kind: 'openai-compatible', id: str('id') ?? newProviderId(),
|
|
190
|
+
baseUrl: normalizeOpenAiBaseUrl(baseUrl),
|
|
191
|
+
apiKeyEnv: str('apiKeyEnv'), label: str('label'), model: str('model'),
|
|
192
|
+
...(p.needsKey === true ? { needsKey: true } : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
default:
|
|
196
|
+
return `unknown provider kind: ${String(p.kind)}`;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Names a provider's Foreman-owned config dir; stable for its lifetime. */
|
|
201
|
+
function newProviderId(): string {
|
|
202
|
+
return `pr-${crypto.randomBytes(4).toString('hex')}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A basis and the deprecated boolean that shadows it, so they cannot drift. */
|
|
206
|
+
function basisOf(costBasis: CostBasis): { costBasis: CostBasis; metered: boolean } {
|
|
207
|
+
return { costBasis, metered: costBasis === 'priced' };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Billing modes the UI understands; a superset of the server's own AuthMode. */
|
|
211
|
+
type BillingMode = AuthMode | 'local' | 'provider';
|
|
212
|
+
|
|
213
|
+
/** One selectable model, tagged with the provider that serves it. */
|
|
214
|
+
interface ModelOption {
|
|
215
|
+
id: string;
|
|
216
|
+
label: string;
|
|
217
|
+
model: string;
|
|
218
|
+
/** Which provider serves it; absent means the project's own/server default. */
|
|
219
|
+
providerId?: string;
|
|
220
|
+
providerLabel: string;
|
|
221
|
+
cost?: number;
|
|
222
|
+
note?: string;
|
|
223
|
+
/** What spending on this model is: priced, free, or real-but-unquantified. */
|
|
224
|
+
costBasis: CostBasis;
|
|
225
|
+
/** @deprecated Mirrors `costBasis === 'priced'` for older clients. */
|
|
226
|
+
metered: boolean;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Everything this machine can run a mission on.
|
|
231
|
+
*
|
|
232
|
+
* Deliberately generous: a provider that is merely *present* is offered, even
|
|
233
|
+
* if the current project does not use it, because the picker is where someone
|
|
234
|
+
* decides to use it. Anything unreachable is simply absent rather than listed
|
|
235
|
+
* and broken — a picker's job is to offer what will work.
|
|
236
|
+
*/
|
|
237
|
+
async function availableModels(project: Project | null): Promise<{
|
|
238
|
+
models: ModelOption[];
|
|
239
|
+
groups: Array<{ providerId?: string; label: string; count: number }>;
|
|
240
|
+
reachable: boolean;
|
|
241
|
+
}> {
|
|
242
|
+
const out: ModelOption[] = [];
|
|
243
|
+
|
|
244
|
+
// Anthropic, via whichever Claude Code install or key the server resolves.
|
|
245
|
+
// Always offered: it is the default, and the shipped configuration.
|
|
246
|
+
for (const m of MODELS) {
|
|
247
|
+
out.push({ ...m, providerLabel: 'Anthropic', costBasis: 'priced', metered: true });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The project's own provider, when it is an endpoint of its own. Asked
|
|
251
|
+
// first-hand, because a project pointed at another machine must be offered
|
|
252
|
+
// that machine's models rather than this one's.
|
|
253
|
+
const pinned = project ? providerOf(project) : null;
|
|
254
|
+
if (pinned?.kind === 'openai-compatible') {
|
|
255
|
+
const resolved = await resolveProvider(pinned, store.root);
|
|
256
|
+
const found = await discoverModels(resolved.upstreamUrl ?? pinned.baseUrl, {
|
|
257
|
+
apiKey: resolved.apiKey,
|
|
258
|
+
});
|
|
259
|
+
const onOpenAi = isOpenAiHost(resolved.upstreamUrl);
|
|
260
|
+
for (const raw of found ?? []) {
|
|
261
|
+
// Direct OpenAI publishes no rates; the dated list in openai-prices.ts
|
|
262
|
+
// stands in, and says so in the note. A published rate still wins.
|
|
263
|
+
const listed = onOpenAi && !raw.price ? openaiPrice(raw.id) : null;
|
|
264
|
+
const m = listed ? { ...raw, price: listed } : raw;
|
|
265
|
+
out.push({
|
|
266
|
+
id: m.id, label: m.id, model: m.id,
|
|
267
|
+
providerId: pinned.id, providerLabel: pinned.label ?? 'Custom endpoint',
|
|
268
|
+
cost: costRank(m), note: listed ? (openaiPriceNote(m.id) ?? describeModel(m)) : describeModel(m),
|
|
269
|
+
// Three-way, in the order the facts outrank each other. A published
|
|
270
|
+
// rate settles it. Otherwise the endpoint sets the floor and the model
|
|
271
|
+
// can raise it: a daemon on this machine is free, but a `:cloud` model
|
|
272
|
+
// it merely proxies runs on somebody's paid servers, and calling that
|
|
273
|
+
// free is the error that costs money.
|
|
274
|
+
...basisOf(m.price ? 'priced'
|
|
275
|
+
: m.remote || !isLoopback(resolved.upstreamUrl) ? 'unpriced' : 'free'),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// A running local Ollama, whether or not any project uses it yet.
|
|
281
|
+
const localOllama = pinned?.kind === 'openai-compatible'
|
|
282
|
+
&& normalizeOpenAiBaseUrl(pinned.baseUrl) === normalizeOpenAiBaseUrl(ollamaHost());
|
|
283
|
+
if (!localOllama) {
|
|
284
|
+
const models = await discoverOllama();
|
|
285
|
+
for (const m of models ?? []) {
|
|
286
|
+
out.push({
|
|
287
|
+
id: m.id, label: m.id, model: m.id,
|
|
288
|
+
providerId: 'ollama-local', providerLabel: 'Ollama',
|
|
289
|
+
cost: costRank(m), note: describeModel(m),
|
|
290
|
+
// A model served from this machine costs nothing per token; a `:cloud`
|
|
291
|
+
// one is real spend Foreman cannot price. Neither gets a dollar
|
|
292
|
+
// figure, but they are not the same thing to tell someone.
|
|
293
|
+
...basisOf(m.remote ? 'unpriced' : 'free'),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// A signed-in Codex install.
|
|
299
|
+
const home = codexHome();
|
|
300
|
+
const codexAuth = await readCodexAuth(home).catch(() => null);
|
|
301
|
+
if (codexAuth) {
|
|
302
|
+
for (const id of await codexModels(home)) {
|
|
303
|
+
out.push({
|
|
304
|
+
id, label: id, model: id,
|
|
305
|
+
providerId: 'codex-local', providerLabel: 'Codex',
|
|
306
|
+
cost: 2, note: 'Runs on your ChatGPT subscription.',
|
|
307
|
+
// A plan being drawn down, not a free lunch.
|
|
308
|
+
...basisOf('unpriced'),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const groups = [...new Map(out.map((m) => [m.providerLabel, m])).values()]
|
|
314
|
+
.map((m) => ({
|
|
315
|
+
providerId: m.providerId,
|
|
316
|
+
label: m.providerLabel,
|
|
317
|
+
count: out.filter((x) => x.providerLabel === m.providerLabel).length,
|
|
318
|
+
}));
|
|
319
|
+
|
|
320
|
+
return { models: out, groups, reachable: true };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The provider serving one role.
|
|
325
|
+
*
|
|
326
|
+
* A model carries the provider that serves it, so a run can put its director
|
|
327
|
+
* on one and its workers on another. An id that no longer resolves falls back
|
|
328
|
+
* to the run's own provider rather than failing: a provider removed between
|
|
329
|
+
* dispatch and resume should degrade to the project's, not strand the run.
|
|
330
|
+
*/
|
|
331
|
+
function providerForRole(meta: RunMeta, roleProviderId?: string): ProviderRef {
|
|
332
|
+
const own = providerOf(meta);
|
|
333
|
+
if (!roleProviderId) return own;
|
|
334
|
+
if ('id' in own && own.id === roleProviderId) return own;
|
|
335
|
+
// The two providers the machine offers without being configured for them.
|
|
336
|
+
if (roleProviderId === 'ollama-local') return ollamaProvider();
|
|
337
|
+
if (roleProviderId === 'codex-local') return { kind: 'codex', id: 'codex-local' };
|
|
338
|
+
return own;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Whether a project's provider has a key on file. Never the key itself. */
|
|
342
|
+
async function providerHasKeyOf(p: Project): Promise<boolean> {
|
|
343
|
+
const ref = providerOf(p);
|
|
344
|
+
return 'id' in ref && ref.id ? hasSecret(store.root, ref.id) : false;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Is this endpoint on this machine? Decides "free" from "somebody's meter". */
|
|
348
|
+
function isLoopback(url: string | undefined): boolean {
|
|
349
|
+
if (!url) return false;
|
|
350
|
+
try {
|
|
351
|
+
const host = new URL(url).hostname;
|
|
352
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
|
353
|
+
} catch {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Trims an optional path field from a request body; '' means "cleared". */
|
|
359
|
+
function toPath(v: unknown): string | undefined {
|
|
360
|
+
return typeof v === 'string' && v.trim() ? v.trim() : undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Parses a model choice: a known alias or a full claude-* id; else inherit. */
|
|
364
|
+
/**
|
|
365
|
+
* A model id from any provider.
|
|
366
|
+
*
|
|
367
|
+
* This used to accept only Anthropic aliases and `claude-*` ids, which meant a
|
|
368
|
+
* picked Ollama or Codex model was silently dropped and the run quietly fell
|
|
369
|
+
* back to the default — the failure looked like a successful mission on the
|
|
370
|
+
* wrong model. Now that a model carries the provider that serves it, the
|
|
371
|
+
* shapes are whatever those providers use (`glm-5.3-flash:cloud`,
|
|
372
|
+
* `gpt-5.6-sol`, `qwen3.8:27b-q8_0`), so this validates the *characters* a
|
|
373
|
+
* model id may contain rather than trying to recognise a vendor.
|
|
374
|
+
*/
|
|
375
|
+
function modelChoice(v: unknown): ModelChoice {
|
|
376
|
+
if (typeof v !== 'string' || !v.trim()) return undefined;
|
|
377
|
+
const id = v.trim();
|
|
378
|
+
return /^[A-Za-z0-9._:\/-]{1,120}$/.test(id) ? id : undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Directory names never descended into by the drag-drop folder locator. */
|
|
382
|
+
const LOCATE_SKIP = new Set([
|
|
383
|
+
'node_modules', 'Library', 'Applications', '.Trash', 'Music', 'Movies',
|
|
384
|
+
'Pictures', 'dist', 'build', 'target', 'vendor', '.git',
|
|
385
|
+
]);
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Breadth-first search under $HOME for directories whose basename matches
|
|
389
|
+
* `name` (case-insensitive). Bounded by depth, visit count, and wall clock so
|
|
390
|
+
* a request can never wander the whole disk.
|
|
391
|
+
*/
|
|
392
|
+
async function locateFolders(name: string): Promise<string[]> {
|
|
393
|
+
const wanted = name.toLowerCase();
|
|
394
|
+
const results: string[] = [];
|
|
395
|
+
const queue: Array<{ dir: string; depth: number }> = [{ dir: os.homedir(), depth: 0 }];
|
|
396
|
+
const deadline = Date.now() + 2000;
|
|
397
|
+
let visited = 0;
|
|
398
|
+
|
|
399
|
+
while (queue.length && results.length < 15 && visited < 20000 && Date.now() < deadline) {
|
|
400
|
+
const { dir, depth } = queue.shift()!;
|
|
401
|
+
visited++;
|
|
402
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
403
|
+
for (const e of entries) {
|
|
404
|
+
if (!e.isDirectory() || e.name.startsWith('.') || LOCATE_SKIP.has(e.name)) continue;
|
|
405
|
+
const full = path.join(dir, e.name);
|
|
406
|
+
if (e.name.toLowerCase() === wanted) results.push(full);
|
|
407
|
+
if (depth < 4) queue.push({ dir: full, depth: depth + 1 });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return results;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const PORT = Number(process.env.PORT ?? 4177);
|
|
414
|
+
/**
|
|
415
|
+
* Where to listen. Default: loopback, plus the tailnet address when this
|
|
416
|
+
* machine is on one — never every interface, since there is no login.
|
|
417
|
+
* `FOREMAN_BIND=all` opens it wide on purpose (a trusted LAN, a container);
|
|
418
|
+
* `FOREMAN_BIND=local` keeps it to this machine even with Tailscale up.
|
|
419
|
+
*/
|
|
420
|
+
const BIND = (process.env.FOREMAN_BIND ?? 'auto') as 'auto' | 'all' | 'local';
|
|
421
|
+
const tailnet = BIND === 'local' ? null : await detectTailscale();
|
|
422
|
+
/** Dev servers the crew put behind /svc/ — see services.ts. */
|
|
423
|
+
const services = new ServiceRegistry();
|
|
424
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
425
|
+
const DIST_DIR = path.join(__dirname, '..', 'ui', 'dist');
|
|
426
|
+
const ROOT_ASSETS = new Set(['/favicon.svg']);
|
|
427
|
+
|
|
428
|
+
const store = new RunStore(process.env.FOREMAN_HOME || undefined);
|
|
429
|
+
|
|
430
|
+
/** Detected once: env is immutable for this process, and /projects polls at 3s. */
|
|
431
|
+
const authPromise = detectAuth();
|
|
432
|
+
/** Active runs by projectId (at most one per project); `null` marks a
|
|
433
|
+
* reservation taken synchronously before the run object exists. */
|
|
434
|
+
const activeByProject = new Map<string, MissionRun | null>();
|
|
435
|
+
const sseClients = new Set<http.ServerResponse>();
|
|
436
|
+
|
|
437
|
+
function activeRuns(): MissionRun[] {
|
|
438
|
+
// Filter out reservation placeholders (see reserveProject).
|
|
439
|
+
return [...activeByProject.values()].filter((r): r is MissionRun => Boolean(r));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Broadcasts an enveloped frame to live clients and persists the bare event. */
|
|
443
|
+
function makeEmitter(runId: string, projectId: string) {
|
|
444
|
+
return (event: string, data: unknown): void => {
|
|
445
|
+
const evt: ForemanEvent = { ts: Date.now(), event, data };
|
|
446
|
+
const frame =
|
|
447
|
+
`event: ${event}\ndata: ${JSON.stringify({ runId, projectId, data })}\n\n`;
|
|
448
|
+
for (const res of sseClients) res.write(frame);
|
|
449
|
+
void store.append(runId, evt);
|
|
450
|
+
// The one place notifications hang off the mission stream. Labels are
|
|
451
|
+
// cached here from the events themselves so a message can name the run
|
|
452
|
+
// without a disk read on the emitter's path.
|
|
453
|
+
const d = (data ?? {}) as Record<string, unknown>;
|
|
454
|
+
if (event === 'run_started' || event === 'run_resumed') {
|
|
455
|
+
runLabelCache.set(runId, { ...runLabelCache.get(runId), mission: String(d.mission ?? '') });
|
|
456
|
+
} else if (event === 'run_titled') {
|
|
457
|
+
runLabelCache.set(runId, { ...runLabelCache.get(runId), title: String(d.title ?? '') });
|
|
458
|
+
}
|
|
459
|
+
if (!projectsCache.has(projectId)) {
|
|
460
|
+
void store.getProject(projectId).then((p) => { if (p) projectsCache.set(projectId, { name: p.name }); });
|
|
461
|
+
}
|
|
462
|
+
notifyHub.handle({ event, runId, projectId, data: d, ts: evt.ts });
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Everything an agent needs to authenticate, including starting the provider's
|
|
468
|
+
* gateway if it has one.
|
|
469
|
+
*
|
|
470
|
+
* Both dispatch paths (missions and planning turns) go through here, so a
|
|
471
|
+
* gateway can never be skipped on one of them — which would leave the agent
|
|
472
|
+
* pointed at a closed port with a real credential in hand.
|
|
473
|
+
*/
|
|
474
|
+
async function agentEnvFor(
|
|
475
|
+
resolved: ResolvedProvider, holder?: string, ledgerKey?: string,
|
|
476
|
+
): Promise<ReturnType<typeof providerEnv>> {
|
|
477
|
+
await mkdir(resolved.configDir, { recursive: true }).catch(() => {});
|
|
478
|
+
if (resolved.wire === 'anthropic-native') return providerEnv(resolved);
|
|
479
|
+
// `holder` keeps the gateway alive for as long as this run needs it — see
|
|
480
|
+
// the reaper in gateway.ts.
|
|
481
|
+
const url = await ensureGateway(resolved, holder);
|
|
482
|
+
// The run key rides on the path, which the Agent SDK preserves (measured:
|
|
483
|
+
// it sends `POST /run/<key>/v1/messages`). That is what lets one gateway
|
|
484
|
+
// serving several runs still say which one spent what.
|
|
485
|
+
return providerEnv(resolved, ledgerKey ? `${url}/run/${encodeURIComponent(ledgerKey)}` : url);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The ledger bucket for this attempt at a run.
|
|
490
|
+
*
|
|
491
|
+
* The attempt number is part of the key on purpose. A gateway can outlive the
|
|
492
|
+
* run that started it, so a resumed run whose earlier tokens are already
|
|
493
|
+
* persisted in `meta.usage` would otherwise read them back out of the ledger
|
|
494
|
+
* and count them twice. A fresh bucket per attempt makes "persisted total plus
|
|
495
|
+
* what this attempt has spent" exactly right.
|
|
496
|
+
*/
|
|
497
|
+
function ledgerKeyFor(meta: RunMeta): string {
|
|
498
|
+
return `${meta.id}.${meta.resumes ?? 0}`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Chat frames carry `chat: true` and no run id, so a UI following the same
|
|
503
|
+
* stream can tell a planning conversation from a mission without guessing.
|
|
504
|
+
*/
|
|
505
|
+
/**
|
|
506
|
+
* A chat event for open tabs only, not the log: the log it would describe
|
|
507
|
+
* is the one being thrown away. Used when a conversation is cleared, so a
|
|
508
|
+
* tab still showing the old proposal and cost drops them.
|
|
509
|
+
*/
|
|
510
|
+
function broadcastChat(projectId: string, event: string, data: unknown): void {
|
|
511
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify({ runId: null, projectId, chat: true, data })}\n\n`;
|
|
512
|
+
for (const res of sseClients) res.write(frame);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function makeChatEmitter(projectId: string) {
|
|
516
|
+
return (event: string, data: unknown): void => {
|
|
517
|
+
const evt: ForemanEvent = { ts: Date.now(), event, data };
|
|
518
|
+
const frame =
|
|
519
|
+
`event: ${event}\ndata: ${JSON.stringify({ runId: null, projectId, chat: true, data })}\n\n`;
|
|
520
|
+
for (const res of sseClients) res.write(frame);
|
|
521
|
+
void store.appendChat(projectId, evt);
|
|
522
|
+
if (!projectsCache.has(projectId)) {
|
|
523
|
+
void store.getProject(projectId).then((p) => { if (p) projectsCache.set(projectId, { name: p.name }); });
|
|
524
|
+
}
|
|
525
|
+
notifyHub.handle({ event, runId: null, projectId, chat: true, data: (data ?? {}) as Record<string, unknown>, ts: evt.ts });
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Projects with a planning turn in flight. One turn at a time per project:
|
|
531
|
+
* two concurrent turns would resume the same session and race to write the
|
|
532
|
+
* session id, quietly forking the conversation.
|
|
533
|
+
*/
|
|
534
|
+
const chatTurns = new Set<string>();
|
|
535
|
+
/** The in-flight turn's abort handle per project, so a human can stop it. */
|
|
536
|
+
const chatAborts = new Map<string, AbortController>();
|
|
537
|
+
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// Notifications
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
/** Global settings the channel reads: the same toggles the tab uses, plus where links point. */
|
|
543
|
+
async function notifySettings(): Promise<{
|
|
544
|
+
prefs: { needsYou: boolean; done: boolean; budget: boolean };
|
|
545
|
+
publicUrl: string;
|
|
546
|
+
telegramChatId?: string; telegramChatLabel?: string; telegramBot?: string;
|
|
547
|
+
}> {
|
|
548
|
+
const g = (await store.readSettings().catch(() => ({ global: {}, projects: {} }))).global as Record<string, unknown>;
|
|
549
|
+
const on = (k: string, dflt: boolean) => (typeof g[k] === 'boolean' ? (g[k] as boolean) : dflt);
|
|
550
|
+
const str = (k: string) => (typeof g[k] === 'string' && (g[k] as string).trim() ? (g[k] as string).trim() : undefined);
|
|
551
|
+
return {
|
|
552
|
+
prefs: { needsYou: on('notifyNeedsYou', true), done: on('notifyDone', true), budget: on('notifyBudget', true) },
|
|
553
|
+
// Unset: the tailnet name when there is one — the phone can open that —
|
|
554
|
+
// else localhost, which only this machine can.
|
|
555
|
+
publicUrl: (() => {
|
|
556
|
+
const saved = str('publicUrl');
|
|
557
|
+
// A saved localhost is the old default, not a preference: no phone can
|
|
558
|
+
// open it, so a tailnet name wins over it. Any other saved URL stands.
|
|
559
|
+
const isLocal = !saved || /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/?$/i.test(saved);
|
|
560
|
+
return ((isLocal && tailnet) ? tailnetUrl(tailnet, PORT) : (saved ?? `http://localhost:${PORT}`)).replace(/\/+$/, '');
|
|
561
|
+
})(),
|
|
562
|
+
telegramChatId: str('telegramChatId'),
|
|
563
|
+
telegramChatLabel: str('telegramChatLabel'),
|
|
564
|
+
telegramBot: str('telegramBot'),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The context the hub shapes messages with. Read per event rather than cached
|
|
570
|
+
* so a Settings change — a toggle, a new public URL — applies to the next
|
|
571
|
+
* message, and so the project and run names come from live state. Cheap: a
|
|
572
|
+
* small JSON file and two Map lookups, on events that happen a few times a
|
|
573
|
+
* mission.
|
|
574
|
+
*/
|
|
575
|
+
let notifyCtxCache: { at: number; value: Awaited<ReturnType<typeof notifySettings>> } | null = null;
|
|
576
|
+
const notifyHub = new NotifyHub(() => {
|
|
577
|
+
const s = notifyCtxCache?.value ?? { prefs: { needsYou: true, done: true, budget: true }, publicUrl: tailnet ? tailnetUrl(tailnet, PORT) : `http://localhost:${PORT}` };
|
|
578
|
+
if (!notifyCtxCache || Date.now() - notifyCtxCache.at > 5_000) {
|
|
579
|
+
void notifySettings().then((v) => { notifyCtxCache = { at: Date.now(), value: v }; });
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
prefs: s.prefs,
|
|
583
|
+
publicUrl: s.publicUrl,
|
|
584
|
+
projectName: (id) => projectsCache.get(id)?.name,
|
|
585
|
+
runLabel: (id) => { const r = runLabelCache.get(id); return r?.title || r?.mission; },
|
|
586
|
+
};
|
|
587
|
+
});
|
|
588
|
+
const projectsCache = new Map<string, { name: string }>();
|
|
589
|
+
const runLabelCache = new Map<string, { title?: string; mission?: string }>();
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The one reader of the bot's updates — link codes, button taps, replies.
|
|
593
|
+
* Created whenever a token exists (linking needs it before any chat is
|
|
594
|
+
* linked); the transport is attached only once a chat is.
|
|
595
|
+
*/
|
|
596
|
+
let telegramBot: TelegramBot | null = null;
|
|
597
|
+
|
|
598
|
+
/** (Re)build the Telegram side from the stored token and linked chat. */
|
|
599
|
+
async function reattachTelegram(): Promise<boolean> {
|
|
600
|
+
notifyHub.detach('telegram');
|
|
601
|
+
const s = await notifySettings();
|
|
602
|
+
const token = await getSecret(store.root, 'telegram');
|
|
603
|
+
if (!token) { void telegramBot?.stop(); telegramBot = null; return false; }
|
|
604
|
+
if (!telegramBot) {
|
|
605
|
+
telegramBot = new TelegramBot(token, undefined, s.telegramChatId ?? null, {
|
|
606
|
+
// Taps and replies from the linked chat become the same calls the tab
|
|
607
|
+
// makes, through the hub — the channel never learns Foreman's routes.
|
|
608
|
+
onCallback: (data, messageId) => notifyHub.handleCallback(data, messageId),
|
|
609
|
+
onText: (text, replyTo) => void handlePhoneText(text, replyTo),
|
|
610
|
+
});
|
|
611
|
+
telegramBot.start();
|
|
612
|
+
// The phone's "/" menu. Best effort: a failure here costs the menu, not the bot.
|
|
613
|
+
void setBotCommands(token).then((ok) => { if (!ok) console.warn('[telegram] could not register the command menu'); });
|
|
614
|
+
}
|
|
615
|
+
telegramBot.linkedChatId = s.telegramChatId ?? null;
|
|
616
|
+
if (!s.telegramChatId) return false;
|
|
617
|
+
notifyHub.attach(telegramTransport(token, s.telegramChatId));
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// An answer from the channel resolves exactly as one from the tab would. The
|
|
622
|
+
// orchestrator emits the same events, the transcript shows the same entry,
|
|
623
|
+
// and the phone's message is edited by that event like any other resolution.
|
|
624
|
+
notifyHub.onAnswer((a) => {
|
|
625
|
+
if (a.kind === 'perm') {
|
|
626
|
+
activeRuns().some((r) => r.resolvePermission(a.id, a.behavior));
|
|
627
|
+
} else if (a.kind === 'q') {
|
|
628
|
+
activeRuns().some((r) => r.answerQuestion(a.id, a.text));
|
|
629
|
+
} else if (a.kind === 'cq') {
|
|
630
|
+
if (answerChatQuestion(a.projectId, a.id, a.answers)) {
|
|
631
|
+
makeChatEmitter(a.projectId)('chat_answered', { id: a.id, answers: a.answers, source: 'telegram' });
|
|
632
|
+
}
|
|
633
|
+
} else if (a.kind === 'proposal') {
|
|
634
|
+
void (async () => {
|
|
635
|
+
const project = await store.getProject(a.projectId);
|
|
636
|
+
const meta = await store.readChatMeta(a.projectId).catch(() => null);
|
|
637
|
+
const prop = meta?.proposal;
|
|
638
|
+
if (!project || !prop) { void notifyHub.say('That proposal is no longer there.'); return; }
|
|
639
|
+
if (a.action === 'discard') {
|
|
640
|
+
const { proposal: _gone, ...rest } = meta!;
|
|
641
|
+
await store.writeChatMeta({ ...rest, updatedAt: Date.now() });
|
|
642
|
+
broadcastChat(a.projectId, 'chat_proposal_dismissed', {});
|
|
643
|
+
void notifyHub.say(`Discarded the proposal for <b>${escTg(project.name)}</b>. Tell the planner what to change.`);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (!reserveProject(a.projectId)) { void notifyHub.say(`<b>${escTg(project.name)}</b> already has an active mission.`); return; }
|
|
647
|
+
// Exactly what the card in the browser would start: the proposal's
|
|
648
|
+
// brief, its budget, its models and its browser judgement.
|
|
649
|
+
void startRun(a.projectId, project.folder, prop.mission, prop.budgetUsd,
|
|
650
|
+
modelChoice(prop.directorModel), modelChoice(prop.workerModel), prop.browser === true,
|
|
651
|
+
providerOf(project), { director: prop.directorProviderId, worker: prop.workerProviderId });
|
|
652
|
+
void notifyHub.say(`Started <b>${escTg(project.name)}</b> as proposed, cap $${prop.budgetUsd}.`);
|
|
653
|
+
})();
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
/** The project the phone last planned with; plain text continues it. */
|
|
658
|
+
let lastPhonePlanning: string | null = null;
|
|
659
|
+
|
|
660
|
+
/** A project by name (case-insensitive), id, or folder basename. */
|
|
661
|
+
async function findProject(ref: string): Promise<Project | null> {
|
|
662
|
+
const want = ref.trim().toLowerCase();
|
|
663
|
+
const all = await store.listProjects();
|
|
664
|
+
return all.find((p) => p.id === ref)
|
|
665
|
+
?? all.find((p) => p.name.toLowerCase() === want)
|
|
666
|
+
?? all.find((p) => path.basename(p.folder).toLowerCase() === want)
|
|
667
|
+
?? null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Text from the linked chat. In order: a command; an answer to an open ask
|
|
672
|
+
* (the hub's job); a continuation of the last planning conversation the
|
|
673
|
+
* phone started; else the help text — never silence.
|
|
674
|
+
*/
|
|
675
|
+
async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
|
|
676
|
+
const say = (t: string) => void notifyHub.say(t);
|
|
677
|
+
const cmd = parseCommand(text);
|
|
678
|
+
if (cmd) {
|
|
679
|
+
try {
|
|
680
|
+
switch (cmd.cmd) {
|
|
681
|
+
case 'help': return say(HELP_TEXT);
|
|
682
|
+
case 'projects': {
|
|
683
|
+
const all = await store.listProjects();
|
|
684
|
+
if (!all.length) return say('No projects linked yet. /new <name> creates one.');
|
|
685
|
+
const runs = await store.listRuns();
|
|
686
|
+
const lines = all.map((p) => {
|
|
687
|
+
const live = activeByProject.has(p.id);
|
|
688
|
+
const last = runs.filter((r) => r.folder === p.folder).sort((a, b) => b.createdAt - a.createdAt)[0];
|
|
689
|
+
return `• <b>${escTg(p.name)}</b> — ${live ? 'running' : last ? `last run ${last.status}` : 'no runs yet'}`;
|
|
690
|
+
});
|
|
691
|
+
return say(`<b>Fleet</b>\n${lines.join('\n')}`);
|
|
692
|
+
}
|
|
693
|
+
case 'status': {
|
|
694
|
+
const live = activeRuns();
|
|
695
|
+
// Planners count as activity too: "all quiet" while one is drafting
|
|
696
|
+
// a proposal for you read as a lie the first time it happened.
|
|
697
|
+
const planning: string[] = [];
|
|
698
|
+
for (const id of chatTurns) {
|
|
699
|
+
const name = projectsCache.get(id)?.name ?? (await store.getProject(id))?.name ?? id;
|
|
700
|
+
planning.push(`• <b>${escTg(name)}</b> — the planner is replying`);
|
|
701
|
+
}
|
|
702
|
+
for (const id of await store.listChatIds()) {
|
|
703
|
+
if (chatTurns.has(id)) continue;
|
|
704
|
+
const m = await store.readChatMeta(id).catch(() => null);
|
|
705
|
+
if (m?.proposal) {
|
|
706
|
+
const name = projectsCache.get(id)?.name ?? (await store.getProject(id))?.name ?? id;
|
|
707
|
+
planning.push(`• <b>${escTg(name)}</b> — a proposal is waiting for Start or Discard`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (!live.length && !planning.length) return say('All quiet — nothing running, nothing waiting on you.');
|
|
711
|
+
if (!live.length) return say(`<b>Planning</b>\n${planning.join('\n')}`);
|
|
712
|
+
const lines = live.map((r) => {
|
|
713
|
+
const name = projectsCache.get(r.meta.projectId ?? '')?.name ?? r.meta.folder;
|
|
714
|
+
const spend = r.meta.costBasis === 'priced' ? `$${r.meta.costUsd.toFixed(2)} of $${r.meta.budgetUsd}` : 'unpriced';
|
|
715
|
+
const asks = r.pendingAsks().length;
|
|
716
|
+
return `• <b>${escTg(name)}</b> — ${escTg(r.meta.title || r.meta.mission.split('\n')[0].slice(0, 80))}\n ${spend}${asks ? ` · <b>${asks} waiting on you</b>` : ''}`;
|
|
717
|
+
});
|
|
718
|
+
return say(`<b>Running · ${live.length}</b>\n${lines.join('\n')}${planning.length ? `\n\n<b>Planning</b>\n${planning.join('\n')}` : ''}`);
|
|
719
|
+
}
|
|
720
|
+
case 'new': {
|
|
721
|
+
const settings = await store.readSettings().catch(() => ({ global: {}, projects: {} }));
|
|
722
|
+
const root = projectsRoot((settings.global as Record<string, unknown>).projectsRoot);
|
|
723
|
+
const name = slug(cmd.name);
|
|
724
|
+
if (!name) return say('That name leaves nothing to call a folder. Try letters and digits.');
|
|
725
|
+
const folder = path.join(root, name);
|
|
726
|
+
if (await findProject(name)) return say(`<b>${escTg(name)}</b> is already linked. /plan ${escTg(name)} <what you want>`);
|
|
727
|
+
await mkdir(folder, { recursive: true });
|
|
728
|
+
const project = await store.addProject(folder, cmd.name.trim());
|
|
729
|
+
projectsCache.set(project.id, { name: project.name });
|
|
730
|
+
lastPhonePlanning = project.id;
|
|
731
|
+
return say(`Created <b>${escTg(project.name)}</b> at <code>${escTg(folder)}</code> and linked it.\nNow tell me what it should do — just type it, or /plan ${escTg(name)} <what you want>.`);
|
|
732
|
+
}
|
|
733
|
+
case 'plan': case 'run': {
|
|
734
|
+
const project = await findProject(cmd.project);
|
|
735
|
+
if (!project) return say(`No project called <b>${escTg(cmd.project)}</b>. /projects lists them; /new creates one.`);
|
|
736
|
+
if (activeByProject.has(project.id)) return say(`<b>${escTg(project.name)}</b> has a mission running — /status shows it.`);
|
|
737
|
+
if (cmd.cmd === 'plan') {
|
|
738
|
+
if (chatTurns.has(project.id)) return say('The planner is still replying — /stop ends that.');
|
|
739
|
+
chatTurns.add(project.id);
|
|
740
|
+
void driveChatTurn(project, cmd.text, cmd.text, 'telegram');
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
// /run: skip the talk. The project's default cap bounds it; the
|
|
744
|
+
// browser is off unless the brief says otherwise, like the composer.
|
|
745
|
+
if (!reserveProject(project.id)) return say(`<b>${escTg(project.name)}</b> already has an active mission.`);
|
|
746
|
+
void startRun(project.id, project.folder, cmd.text, project.defaultBudgetUsd,
|
|
747
|
+
modelChoice(undefined), modelChoice(undefined), /screenshot|browser|render|console/i.test(cmd.text),
|
|
748
|
+
providerOf(project));
|
|
749
|
+
return say(`Started a mission on <b>${escTg(project.name)}</b> with a $${project.defaultBudgetUsd} cap. I will tell you when it needs you or ends.`);
|
|
750
|
+
}
|
|
751
|
+
case 'stop': {
|
|
752
|
+
const project = cmd.project ? await findProject(cmd.project) : (lastPhonePlanning ? await store.getProject(lastPhonePlanning) : null);
|
|
753
|
+
const abort = project && chatAborts.get(project.id);
|
|
754
|
+
if (!project || !abort) return say('No planner reply is in flight.');
|
|
755
|
+
dropPendingAsk(project.id);
|
|
756
|
+
abort.abort();
|
|
757
|
+
return say(`Stopped the planner on <b>${escTg(project.name)}</b>.`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
} catch (err) {
|
|
761
|
+
return say(`That failed: ${escTg(err instanceof Error ? err.message : String(err))}`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (notifyHub.handleText(text, replyTo)) return;
|
|
765
|
+
if (lastPhonePlanning) {
|
|
766
|
+
const project = await store.getProject(lastPhonePlanning);
|
|
767
|
+
if (project && !activeByProject.has(project.id)) {
|
|
768
|
+
if (chatTurns.has(project.id)) return say('The planner is still replying — wait, or /stop.');
|
|
769
|
+
chatTurns.add(project.id);
|
|
770
|
+
void driveChatTurn(project, text, text, 'telegram');
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
say(`Nothing is waiting on an answer. ${HELP_TEXT}`);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** One linking attempt at a time; a new code cancels the previous wait. */
|
|
778
|
+
let telegramLink: { code: string; abort(): void; startedAt: number } | null = null;
|
|
779
|
+
|
|
780
|
+
async function patchGlobalSettings(patch: Record<string, unknown>): Promise<void> {
|
|
781
|
+
const all = await store.readSettings().catch(() => ({ global: {}, projects: {} }));
|
|
782
|
+
const global = { ...(all.global as Record<string, unknown>) };
|
|
783
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
784
|
+
if (v === undefined) delete global[k]; else global[k] = v;
|
|
785
|
+
}
|
|
786
|
+
await store.writeSettings({ ...all, global });
|
|
787
|
+
notifyCtxCache = null;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Reads a project's chat meta, or the empty shape for one never started. */
|
|
791
|
+
async function chatMetaOf(projectId: string): Promise<ChatMeta> {
|
|
792
|
+
const now = Date.now();
|
|
793
|
+
return (await store.readChatMeta(projectId).catch(() => null))
|
|
794
|
+
?? { projectId, costUsd: 0, createdAt: now, updatedAt: now };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Runs one planning turn: the human's message goes into the log first (so a
|
|
799
|
+
* reload mid-turn still shows what was asked), then the planner's reply
|
|
800
|
+
* streams out through the same envelope machinery as a mission.
|
|
801
|
+
*/
|
|
802
|
+
/**
|
|
803
|
+
* `shown` is what the transcript records as the human's message when it
|
|
804
|
+
* differs from what the planner is sent (a fork's seed). `via: 'telegram'`
|
|
805
|
+
* means the phone started this turn: the planner's words go back there, and
|
|
806
|
+
* a proposal it makes gets a card with Start / Discard.
|
|
807
|
+
*/
|
|
808
|
+
async function driveChatTurn(project: Project, text: string, shown: string = text, via?: 'telegram'): Promise<void> {
|
|
809
|
+
const raw = makeChatEmitter(project.id);
|
|
810
|
+
let said = '';
|
|
811
|
+
let asked = false;
|
|
812
|
+
const emit = via !== 'telegram' ? raw : (event: string, data: unknown) => {
|
|
813
|
+
if (event === 'message') {
|
|
814
|
+
const m = (data as { msg?: { type?: string; message?: { content?: Array<{ type?: string; text?: string }> } } }).msg;
|
|
815
|
+
if (m?.type === 'assistant') {
|
|
816
|
+
for (const b of m.message?.content ?? []) if (b.type === 'text' && b.text?.trim()) said = b.text.trim();
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (event === 'chat_question') asked = true;
|
|
820
|
+
if (event === 'mission_proposed') { asked = true; data = { ...(data as object), via }; }
|
|
821
|
+
raw(event, data);
|
|
822
|
+
};
|
|
823
|
+
if (via === 'telegram') lastPhonePlanning = project.id;
|
|
824
|
+
const meta = await chatMetaOf(project.id);
|
|
825
|
+
emit('chat_message', { text: shown, ...(via ? { via } : {}) });
|
|
826
|
+
try {
|
|
827
|
+
const settings = await effectiveSettings(project.id);
|
|
828
|
+
const resolved = await resolveProvider(providerOf(project), store.root);
|
|
829
|
+
// Who is answering, said on every turn. A planning conversation had no
|
|
830
|
+
// visible model or provider at all — the human was talking to "foreman"
|
|
831
|
+
// and could not tell whether that meant Sonnet on their subscription or a
|
|
832
|
+
// local model through a gateway, which decides both the quality of the
|
|
833
|
+
// advice and who is paying for it.
|
|
834
|
+
emit('chat_turn', {
|
|
835
|
+
state: 'thinking',
|
|
836
|
+
model: settings.plannerModel || DEFAULT_PLANNER_MODEL,
|
|
837
|
+
provider: resolved.label,
|
|
838
|
+
costBasis: resolved.costBasis,
|
|
839
|
+
});
|
|
840
|
+
const problem = providerProblem(resolved);
|
|
841
|
+
if (problem) {
|
|
842
|
+
emit('chat_error', { error: `provider unavailable — ${problem}` });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
// What the machine can run, so a recommendation is a real id. Fetched
|
|
846
|
+
// per turn because it is per turn: a provider that came up or went away
|
|
847
|
+
// since the last message changes the answer.
|
|
848
|
+
const { models } = await availableModels(project).catch(() => ({ models: [] }));
|
|
849
|
+
const abort = new AbortController();
|
|
850
|
+
chatAborts.set(project.id, abort);
|
|
851
|
+
const result = await runPlanningTurn({
|
|
852
|
+
projectId: project.id,
|
|
853
|
+
models: models.map((m) => ({
|
|
854
|
+
id: m.id, label: m.label, providerId: m.providerId,
|
|
855
|
+
providerLabel: m.providerLabel, costBasis: m.costBasis, note: m.note,
|
|
856
|
+
})),
|
|
857
|
+
sessionId: meta.sessionId,
|
|
858
|
+
folder: project.folder,
|
|
859
|
+
text,
|
|
860
|
+
model: settings.plannerModel,
|
|
861
|
+
agentEnv: await agentEnvFor(resolved, `chat:${project.id}`),
|
|
862
|
+
emit,
|
|
863
|
+
abort,
|
|
864
|
+
});
|
|
865
|
+
const next: ChatMeta = {
|
|
866
|
+
...meta,
|
|
867
|
+
sessionId: result.sessionId ?? meta.sessionId,
|
|
868
|
+
costUsd: meta.costUsd + result.costUsd,
|
|
869
|
+
updatedAt: Date.now(),
|
|
870
|
+
// A fresh proposal replaces an older unused one: the conversation moved
|
|
871
|
+
// on, and offering the human two drafts of the same mission is worse
|
|
872
|
+
// than offering the current one.
|
|
873
|
+
proposal: result.proposal ?? meta.proposal,
|
|
874
|
+
};
|
|
875
|
+
await store.writeChatMeta(next);
|
|
876
|
+
emit('chat_cost', { costUsd: next.costUsd, turnUsd: result.costUsd });
|
|
877
|
+
if (result.stopped) emit('chat_error', { error: 'Stopped — the rest of this reply was discarded.' });
|
|
878
|
+
else if (result.error) emit('chat_error', { error: result.error });
|
|
879
|
+
// The phone hears the planner's last words unless a card (question or
|
|
880
|
+
// proposal) already said them; a bare "done" would be noise.
|
|
881
|
+
if (via === 'telegram') {
|
|
882
|
+
if (result.error) void notifyHub.say(`<b>${escTg(project.name)}</b> · the planner hit an error: ${escTg(result.error)}`);
|
|
883
|
+
else if (!asked && said) void notifyHub.say(`<b>${escTg(project.name)}</b>\n${escTg(said.slice(0, 3500))}`);
|
|
884
|
+
}
|
|
885
|
+
} catch (err) {
|
|
886
|
+
// runPlanningTurn does not throw; anything here is a Foreman bug or a
|
|
887
|
+
// storage failure, and must not take the server down with it.
|
|
888
|
+
console.error(`planning turn failed for project ${project.id}:`, err);
|
|
889
|
+
emit('chat_error', { error: String(err) });
|
|
890
|
+
} finally {
|
|
891
|
+
chatTurns.delete(project.id);
|
|
892
|
+
chatAborts.delete(project.id);
|
|
893
|
+
emit('chat_turn', { state: 'idle' });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* A mission has started, so the proposal that led to it is spent. Recording
|
|
899
|
+
* the handoff in the conversation matters as much as clearing it: the chat is
|
|
900
|
+
* the story of how this mission came to exist, and it should not simply stop
|
|
901
|
+
* at the moment the work began.
|
|
902
|
+
*/
|
|
903
|
+
async function consumeProposal(projectId: string, runId: string, mission: string): Promise<void> {
|
|
904
|
+
const meta = await store.readChatMeta(projectId).catch(() => null);
|
|
905
|
+
if (!meta) return;
|
|
906
|
+
makeChatEmitter(projectId)('mission_started', { runId, mission });
|
|
907
|
+
if (!meta.proposal) return;
|
|
908
|
+
const { proposal: _spent, ...rest } = meta;
|
|
909
|
+
await store.writeChatMeta({ ...rest, updatedAt: Date.now() }).catch(() => {});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Reserves a project for a new/resumed mission. Synchronous check-and-set:
|
|
914
|
+
* routes call this AFTER their last await and BEFORE any further await, which
|
|
915
|
+
* makes "one active mission per project" race-free on the single JS thread.
|
|
916
|
+
*/
|
|
917
|
+
function reserveProject(projectId: string): boolean {
|
|
918
|
+
if (activeByProject.has(projectId)) return false;
|
|
919
|
+
activeByProject.set(projectId, null); // reservation placeholder
|
|
920
|
+
return true;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** Runs a mission to completion. The project must already be reserved. */
|
|
924
|
+
async function driveRun(
|
|
925
|
+
projectId: string, meta: RunMeta, resume?: { sessionId?: string },
|
|
926
|
+
changes?: { directorChanged: boolean; workerChanged: boolean },
|
|
927
|
+
): Promise<void> {
|
|
928
|
+
const emit = makeEmitter(meta.id, projectId);
|
|
929
|
+
// One resolution per run, from the provider frozen into the run's metadata.
|
|
930
|
+
// A run that cannot resolve a credential must not start: dispatching anyway
|
|
931
|
+
// would fall back to whatever the environment happens to hold.
|
|
932
|
+
const resolved = await resolveProvider(providerOf(meta), store.root);
|
|
933
|
+
const problem = providerProblem(resolved);
|
|
934
|
+
if (problem) {
|
|
935
|
+
meta.status = 'error';
|
|
936
|
+
meta.endedAt = Date.now();
|
|
937
|
+
await store.writeMeta(meta).catch(() => {});
|
|
938
|
+
emit('run_error', { error: `provider unavailable — ${problem}` });
|
|
939
|
+
emit('run_finished', { status: 'error', costUsd: meta.costUsd });
|
|
940
|
+
activeByProject.delete(projectId);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
let agentEnv;
|
|
944
|
+
let roleBasis = resolved.costBasis;
|
|
945
|
+
let prices: { director?: ModelPrice; worker?: ModelPrice } = {};
|
|
946
|
+
let roleBases: { director: CostBasis; worker: CostBasis } | undefined;
|
|
947
|
+
let gatewayRoles = { director: false, worker: false };
|
|
948
|
+
try {
|
|
949
|
+
// Resolved per role. Where both roles share a provider this resolves once
|
|
950
|
+
// and starts one gateway; where they differ, the supervisor already runs a
|
|
951
|
+
// process per provider.
|
|
952
|
+
const directorBase = meta.directorProviderId
|
|
953
|
+
? await resolveProvider(providerForRole(meta, meta.directorProviderId), store.root)
|
|
954
|
+
: resolved;
|
|
955
|
+
const workerBase = meta.workerProviderId === meta.directorProviderId
|
|
956
|
+
? directorBase
|
|
957
|
+
: await resolveProvider(providerForRole(meta, meta.workerProviderId), store.root);
|
|
958
|
+
// Each role's provider carries the model that role will run, so the
|
|
959
|
+
// SDK's aliases (haiku/sonnet/opus) resolve to something its gateway
|
|
960
|
+
// actually serves. Without this, the one call that still used an alias
|
|
961
|
+
// — the run title, on haiku — went upstream as a literal claude-* id and
|
|
962
|
+
// 404'd four times on a kimi gateway while the mission itself ran fine.
|
|
963
|
+
// Two roles on one provider with different models become two objects;
|
|
964
|
+
// they still share a gateway, since the gateway is keyed by upstream.
|
|
965
|
+
const directorProvider = withRoleModel(directorBase, meta.directorModel);
|
|
966
|
+
const workerProvider = withRoleModel(workerBase, meta.workerModel);
|
|
967
|
+
for (const p of new Set([directorProvider, workerProvider])) {
|
|
968
|
+
const roleProblem = providerProblem(p);
|
|
969
|
+
if (roleProblem) throw new Error(roleProblem);
|
|
970
|
+
}
|
|
971
|
+
const key = ledgerKeyFor(meta);
|
|
972
|
+
agentEnv = {
|
|
973
|
+
director: await agentEnvFor(directorProvider, meta.id, key),
|
|
974
|
+
worker: directorProvider === workerProvider
|
|
975
|
+
? await agentEnvFor(directorProvider, meta.id, key)
|
|
976
|
+
: await agentEnvFor(workerProvider, meta.id, key),
|
|
977
|
+
};
|
|
978
|
+
// Only roles that actually go through a gateway are counted there; a
|
|
979
|
+
// native role's tokens arrive on the SDK's own result message, and adding
|
|
980
|
+
// both would double every one of them.
|
|
981
|
+
gatewayRoles = {
|
|
982
|
+
director: directorProvider.wire !== 'anthropic-native',
|
|
983
|
+
worker: workerProvider.wire !== 'anthropic-native',
|
|
984
|
+
};
|
|
985
|
+
const directorCost = await roleCost(directorProvider, meta.directorModel);
|
|
986
|
+
const workerCost = directorProvider === workerProvider && meta.directorModel === meta.workerModel
|
|
987
|
+
? directorCost
|
|
988
|
+
: await roleCost(workerProvider, meta.workerModel);
|
|
989
|
+
roleBasis = combineBasis(directorCost.basis, workerCost.basis);
|
|
990
|
+
prices = { director: directorCost.price, worker: workerCost.price };
|
|
991
|
+
roleBases = { director: directorCost.basis, worker: workerCost.basis };
|
|
992
|
+
} catch (err) {
|
|
993
|
+
meta.status = 'error';
|
|
994
|
+
meta.endedAt = Date.now();
|
|
995
|
+
await store.writeMeta(meta).catch(() => {});
|
|
996
|
+
emit('run_error', { error: String(err instanceof Error ? err.message : err) });
|
|
997
|
+
emit('run_finished', { status: 'error', costUsd: meta.costUsd });
|
|
998
|
+
activeByProject.delete(projectId);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
// Frozen with the provider: whether this run's dollar figure is real money
|
|
1002
|
+
// decides which caps bind, and that must not change under a resume.
|
|
1003
|
+
//
|
|
1004
|
+
// Decided by the ROLES, not the project's own provider. A run whose director
|
|
1005
|
+
// is on Codex and whose workers are on Ollama spends no real dollars, even
|
|
1006
|
+
// though the project is nominally a Claude Code one — reading the basis
|
|
1007
|
+
// off the project would show that run a dollar meter and arm a dollar cap
|
|
1008
|
+
// over spend that never happens. See combineBasis() for how two roles fold
|
|
1009
|
+
// into one answer.
|
|
1010
|
+
//
|
|
1011
|
+
// Recomputed on every dispatch, including a resume, rather than frozen once.
|
|
1012
|
+
// The inputs are already frozen — the role providers live in this run's own
|
|
1013
|
+
// metadata — so this is deterministic and cannot drift with settings. What
|
|
1014
|
+
// it does allow is a run whose flag was computed by older, wrong code to
|
|
1015
|
+
// heal when it is resumed, instead of being permanently stuck against a cap
|
|
1016
|
+
// it should never have had.
|
|
1017
|
+
meta.costBasis = roleBasis;
|
|
1018
|
+
// Written in step so a run started here still reads correctly if it is ever
|
|
1019
|
+
// handled by a build from before the split.
|
|
1020
|
+
meta.metered = roleBasis === 'priced';
|
|
1021
|
+
const run = new MissionRun(meta, emit, (m) => void store.writeMeta(m), agentEnv, prices, {
|
|
1022
|
+
key: ledgerKeyFor(meta), roles: gatewayRoles, read: gatewayUsage,
|
|
1023
|
+
}, roleBases, {
|
|
1024
|
+
// A dev server behind Foreman's address. Declared ports only, and only
|
|
1025
|
+
// ones something is listening on — an agent cannot reserve a path for a
|
|
1026
|
+
// server it has not started.
|
|
1027
|
+
exposeService: async (runId, port, label) => {
|
|
1028
|
+
if (!(await portOpen(port))) return { ok: false, reason: `nothing is listening on 127.0.0.1:${port} — start the server first` };
|
|
1029
|
+
const svc = services.register(runId, port, label);
|
|
1030
|
+
const base = (await notifySettings().catch(() => null))?.publicUrl ?? (tailnet ? tailnetUrl(tailnet, PORT) : `http://localhost:${PORT}`);
|
|
1031
|
+
return { ok: true, url: `${base.replace(/\/+$/, '')}${svc.path}`, path: svc.path };
|
|
1032
|
+
},
|
|
1033
|
+
});
|
|
1034
|
+
activeByProject.set(projectId, run);
|
|
1035
|
+
try {
|
|
1036
|
+
if (changes?.directorChanged || changes?.workerChanged) {
|
|
1037
|
+
const parts = [
|
|
1038
|
+
changes.directorChanged ? `director → ${meta.directorModel}` : null,
|
|
1039
|
+
changes.workerChanged ? `workers → ${meta.workerModel}` : null,
|
|
1040
|
+
].filter(Boolean).join(', ');
|
|
1041
|
+
emit('models_changed', {
|
|
1042
|
+
text: `Models updated from Settings before resume: ${parts}.` +
|
|
1043
|
+
(changes.directorChanged
|
|
1044
|
+
? ' The director starts a fresh session (a model cannot change mid-session);' +
|
|
1045
|
+
' it recovers state from .foreman/MISSION.md.'
|
|
1046
|
+
: ''),
|
|
1047
|
+
directorModel: meta.directorModel, workerModel: meta.workerModel,
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
await run.start(resume);
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
// start() catches mission errors itself; anything reaching here is a
|
|
1053
|
+
// Foreman bug or storage failure. Never let it become an unhandled
|
|
1054
|
+
// rejection that takes the whole server (and other missions) down.
|
|
1055
|
+
console.error(`run ${meta.id} failed outside the mission loop:`, err);
|
|
1056
|
+
meta.status = 'error';
|
|
1057
|
+
meta.endedAt = Date.now();
|
|
1058
|
+
await store.writeMeta(meta).catch(() => {});
|
|
1059
|
+
} finally {
|
|
1060
|
+
// However the run ended, it no longer needs its gateways.
|
|
1061
|
+
releaseGateways(meta.id);
|
|
1062
|
+
if (activeByProject.get(projectId) === run) activeByProject.delete(projectId);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** Effective run configuration: defaults ← global Settings ← project overlay. */
|
|
1067
|
+
async function effectiveSettings(projectId: string): Promise<{
|
|
1068
|
+
toolPolicy: ToolPolicy; autoAllowReadOnly: boolean;
|
|
1069
|
+
directorModel: ModelChoice; workerModel: ModelChoice; plannerModel: ModelChoice;
|
|
1070
|
+
/** Provider serving each role, when Settings pinned one with the model. */
|
|
1071
|
+
directorProviderId?: string; workerProviderId?: string;
|
|
1072
|
+
}> {
|
|
1073
|
+
const s = await store.readSettings()
|
|
1074
|
+
.catch(() => ({ global: {}, projects: {} as Record<string, object> }));
|
|
1075
|
+
const g = s.global as Record<string, unknown>;
|
|
1076
|
+
const p = (s.projects as Record<string, unknown>)[projectId] as Record<string, unknown> ?? {};
|
|
1077
|
+
const str = (v: unknown): string | undefined =>
|
|
1078
|
+
typeof v === 'string' && v.trim() ? v.trim() : undefined;
|
|
1079
|
+
return {
|
|
1080
|
+
toolPolicy: {
|
|
1081
|
+
...DEFAULT_TOOL_POLICY,
|
|
1082
|
+
...(g.toolPolicy as ToolPolicy | undefined),
|
|
1083
|
+
...(p.toolPolicy as ToolPolicy | undefined),
|
|
1084
|
+
},
|
|
1085
|
+
autoAllowReadOnly:
|
|
1086
|
+
(p.autoAllowReadOnly ?? g.autoAllowReadOnly) !== false,
|
|
1087
|
+
directorModel: modelChoice(p.directorModel ?? g.directorModel),
|
|
1088
|
+
workerModel: modelChoice(p.workerModel ?? g.workerModel),
|
|
1089
|
+
plannerModel: modelChoice(p.plannerModel ?? g.plannerModel),
|
|
1090
|
+
directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
|
|
1091
|
+
workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
async function startRun(
|
|
1096
|
+
projectId: string, folder: string, mission: string, budgetUsd: number,
|
|
1097
|
+
directorModel: ModelChoice, workerModel: ModelChoice, browserTools: boolean,
|
|
1098
|
+
provider: ProviderRef,
|
|
1099
|
+
roleProviders: { director?: string; worker?: string } = {},
|
|
1100
|
+
): Promise<void> {
|
|
1101
|
+
const settings = await effectiveSettings(projectId);
|
|
1102
|
+
const meta: RunMeta = {
|
|
1103
|
+
id: newRunId(),
|
|
1104
|
+
projectId,
|
|
1105
|
+
folder, mission, budgetUsd,
|
|
1106
|
+
// An explicit composer choice wins; "Default" inherits from Settings.
|
|
1107
|
+
directorModel: directorModel ?? settings.directorModel,
|
|
1108
|
+
workerModel: workerModel ?? settings.workerModel,
|
|
1109
|
+
// Which provider serves each role — from the model that was picked, so
|
|
1110
|
+
// choosing a model chooses where that role runs.
|
|
1111
|
+
directorProviderId: roleProviders.director ?? settings.directorProviderId,
|
|
1112
|
+
workerProviderId: roleProviders.worker ?? settings.workerProviderId,
|
|
1113
|
+
browserTools: browserTools || undefined,
|
|
1114
|
+
toolPolicy: settings.toolPolicy,
|
|
1115
|
+
autoAllowReadOnly: settings.autoAllowReadOnly,
|
|
1116
|
+
// Frozen at dispatch: a later change to the project or the server default
|
|
1117
|
+
// must not silently move an in-flight or resumed run to another provider,
|
|
1118
|
+
// or another bill.
|
|
1119
|
+
provider,
|
|
1120
|
+
status: 'running', costUsd: 0,
|
|
1121
|
+
createdAt: Date.now(), workers: [],
|
|
1122
|
+
};
|
|
1123
|
+
try {
|
|
1124
|
+
await store.createRun(meta);
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
activeByProject.delete(projectId);
|
|
1127
|
+
console.error(`failed to create run for project ${projectId}:`, err);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
await consumeProposal(projectId, meta.id, mission).catch(() => {});
|
|
1131
|
+
await driveRun(projectId, meta);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/** Resumes an interrupted run by restoring the director's session. */
|
|
1135
|
+
async function resumeRun(projectId: string, meta: RunMeta): Promise<void> {
|
|
1136
|
+
const sessionId = meta.directorSessionId;
|
|
1137
|
+
// Resume re-reads Settings, so changing models or tool policy after a
|
|
1138
|
+
// failure takes effect on the retry. A director session cannot switch
|
|
1139
|
+
// model mid-session, so a changed director model restarts the session
|
|
1140
|
+
// fresh (the mission doc carries the state forward).
|
|
1141
|
+
const settings = await effectiveSettings(projectId);
|
|
1142
|
+
const directorChanged =
|
|
1143
|
+
settings.directorModel !== undefined && settings.directorModel !== meta.directorModel;
|
|
1144
|
+
const workerChanged =
|
|
1145
|
+
settings.workerModel !== undefined && settings.workerModel !== meta.workerModel;
|
|
1146
|
+
if (directorChanged) meta.directorModel = settings.directorModel;
|
|
1147
|
+
if (workerChanged) meta.workerModel = settings.workerModel;
|
|
1148
|
+
meta.toolPolicy = settings.toolPolicy;
|
|
1149
|
+
meta.autoAllowReadOnly = settings.autoAllowReadOnly;
|
|
1150
|
+
meta.status = 'running';
|
|
1151
|
+
meta.endedAt = undefined;
|
|
1152
|
+
meta.resumes = (meta.resumes ?? 0) + 1;
|
|
1153
|
+
await store.writeMeta(meta).catch((err) => {
|
|
1154
|
+
console.error(`failed to persist resume of ${meta.id}:`, err);
|
|
1155
|
+
});
|
|
1156
|
+
// Always a resume, even when the model change forces a fresh session.
|
|
1157
|
+
await driveRun(projectId, meta, { sessionId: directorChanged ? undefined : sessionId }, {
|
|
1158
|
+
directorChanged, workerChanged,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// ---------------------------------------------------------------------------
|
|
1163
|
+
// Static files
|
|
1164
|
+
// ---------------------------------------------------------------------------
|
|
1165
|
+
|
|
1166
|
+
const MIME: Record<string, string> = {
|
|
1167
|
+
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
|
|
1168
|
+
'.svg': 'image/svg+xml', '.json': 'application/json', '.map': 'application/json',
|
|
1169
|
+
'.png': 'image/png', '.woff2': 'font/woff2',
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
async function serveStatic(pathname: string, res: http.ServerResponse): Promise<boolean> {
|
|
1173
|
+
const rel = pathname === '/' ? 'index.html' : pathname.slice(1);
|
|
1174
|
+
const distFile = path.resolve(DIST_DIR, rel);
|
|
1175
|
+
if (!distFile.startsWith(DIST_DIR + path.sep) && distFile !== path.join(DIST_DIR, 'index.html')) {
|
|
1176
|
+
return false;
|
|
1177
|
+
}
|
|
1178
|
+
const body = await readFile(distFile).catch(() => null);
|
|
1179
|
+
if (!body) {
|
|
1180
|
+
if (pathname !== '/') return false;
|
|
1181
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
1182
|
+
res.end('<h1>Foreman</h1><p>UI bundle missing — run <code>npm run ui:build</code> and reload.</p>');
|
|
1183
|
+
return true;
|
|
1184
|
+
}
|
|
1185
|
+
const type = MIME[path.extname(rel)] ?? 'application/octet-stream';
|
|
1186
|
+
// index.html must never be cached — it points at hashed asset names.
|
|
1187
|
+
const cache = rel === 'index.html' ? 'no-cache' : 'public, max-age=31536000, immutable';
|
|
1188
|
+
res.writeHead(200, { 'content-type': type, 'cache-control': cache });
|
|
1189
|
+
res.end(body);
|
|
1190
|
+
return true;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// ---------------------------------------------------------------------------
|
|
1194
|
+
// Request helpers
|
|
1195
|
+
// ---------------------------------------------------------------------------
|
|
1196
|
+
|
|
1197
|
+
async function readBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
|
|
1198
|
+
const chunks: Buffer[] = [];
|
|
1199
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
1200
|
+
if (!chunks.length) return {};
|
|
1201
|
+
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString());
|
|
1202
|
+
return typeof parsed === 'object' && parsed !== null
|
|
1203
|
+
? (parsed as Record<string, unknown>) : {};
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function json(res: http.ServerResponse, code: number, body: unknown): void {
|
|
1207
|
+
res.writeHead(code, { 'content-type': 'application/json' });
|
|
1208
|
+
res.end(JSON.stringify(body));
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// ---------------------------------------------------------------------------
|
|
1212
|
+
// Routes
|
|
1213
|
+
// ---------------------------------------------------------------------------
|
|
1214
|
+
|
|
1215
|
+
const server = http.createServer(async (req, res) => {
|
|
1216
|
+
const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
|
|
1217
|
+
// Services the crew exposed: /svc/<run>/<port>/… goes to 127.0.0.1:<port>,
|
|
1218
|
+
// but only for a pair a run declared. A page served this way asks for its
|
|
1219
|
+
// absolute-path assets (`/app.js`) against Foreman's root; those arrive as
|
|
1220
|
+
// sub-resource requests carrying the service page as Referer, and are
|
|
1221
|
+
// routed to the same service. Documents never are — a typed URL is Foreman's.
|
|
1222
|
+
{
|
|
1223
|
+
const svc = parseServicePath(url.pathname);
|
|
1224
|
+
if (svc) {
|
|
1225
|
+
if (!services.has(svc.runId, svc.port)) { json(res, 404, { error: 'no such service' }); return; }
|
|
1226
|
+
proxyToService(req, res, svc.port, svc.rest, url.search, servicePath(svc.runId, svc.port));
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
const ref = req.headers.referer;
|
|
1230
|
+
const dest = String(req.headers['sec-fetch-dest'] ?? '');
|
|
1231
|
+
if (ref && dest && dest !== 'document' && dest !== 'empty' && !url.pathname.startsWith(SVC_PREFIX)) {
|
|
1232
|
+
try {
|
|
1233
|
+
const via = parseServicePath(new URL(ref).pathname);
|
|
1234
|
+
if (via && services.has(via.runId, via.port)) {
|
|
1235
|
+
proxyToService(req, res, via.port, url.pathname, url.search, servicePath(via.runId, via.port));
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
} catch { /* not a URL we can read — fall through to Foreman's own routes */ }
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
const runEventsMatch = url.pathname.match(/^\/runs\/([^/]+)\/events$/);
|
|
1242
|
+
// The deck: what a run changed and what it produced. Read-only by design —
|
|
1243
|
+
// DESIGN.md §11 — and handled before the chain because it owns two paths
|
|
1244
|
+
// under /runs/{id}/ that nothing else claims.
|
|
1245
|
+
if (await handleDeckRoute(req, res, url, async (id) => {
|
|
1246
|
+
const m = await store.readMeta(id).catch(() => null);
|
|
1247
|
+
return m ? { folder: m.folder } : null;
|
|
1248
|
+
})) return;
|
|
1249
|
+
const runResumeMatch = url.pathname.match(/^\/runs\/([^/]+)\/resume$/);
|
|
1250
|
+
const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
|
|
1251
|
+
const providerKeyMatch = url.pathname.match(/^\/providers\/([A-Za-z0-9_-]{1,64})\/key$/);
|
|
1252
|
+
|
|
1253
|
+
try {
|
|
1254
|
+
if (req.method === 'GET' && (url.pathname === '/'
|
|
1255
|
+
|| url.pathname.startsWith('/assets/')
|
|
1256
|
+
// Root-level static files Vite emits from ui/public. Listed explicitly so
|
|
1257
|
+
// a stray path can never shadow an API route.
|
|
1258
|
+
|| ROOT_ASSETS.has(url.pathname))) {
|
|
1259
|
+
if (!(await serveStatic(url.pathname, res))) json(res, 404, { error: 'not found' });
|
|
1260
|
+
|
|
1261
|
+
} else if (req.method === 'GET' && url.pathname === '/events') {
|
|
1262
|
+
res.writeHead(200, {
|
|
1263
|
+
'content-type': 'text/event-stream',
|
|
1264
|
+
'cache-control': 'no-cache',
|
|
1265
|
+
connection: 'keep-alive',
|
|
1266
|
+
});
|
|
1267
|
+
res.write(': connected\n\n');
|
|
1268
|
+
sseClients.add(res);
|
|
1269
|
+
req.on('close', () => sseClients.delete(res));
|
|
1270
|
+
|
|
1271
|
+
} else if (req.method === 'GET' && url.pathname === '/settings') {
|
|
1272
|
+
json(res, 200, await store.readSettings());
|
|
1273
|
+
|
|
1274
|
+
} else if (req.method === 'PUT' && url.pathname === '/settings') {
|
|
1275
|
+
const { global: g, projectId, project } = await readBody(req);
|
|
1276
|
+
const current = await store.readSettings();
|
|
1277
|
+
const next = {
|
|
1278
|
+
global: typeof g === 'object' && g !== null ? g as Record<string, unknown> : current.global,
|
|
1279
|
+
projects: { ...current.projects },
|
|
1280
|
+
};
|
|
1281
|
+
if (typeof projectId === 'string' && projectId) {
|
|
1282
|
+
if (typeof project === 'object' && project !== null && Object.keys(project).length) {
|
|
1283
|
+
next.projects[projectId] = project as Record<string, unknown>;
|
|
1284
|
+
} else {
|
|
1285
|
+
delete next.projects[projectId];
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
await store.writeSettings(next);
|
|
1289
|
+
json(res, 200, { ok: true });
|
|
1290
|
+
|
|
1291
|
+
} else if (req.method === 'GET' && url.pathname === '/models') {
|
|
1292
|
+
// Every model this machine can reach, not just the project's provider's.
|
|
1293
|
+
//
|
|
1294
|
+
// Scoping the list to one provider made the configuration most worth
|
|
1295
|
+
// having unbuildable: a capable director with cheap local workers needs
|
|
1296
|
+
// two providers in one run, and a caged picker could only offer one.
|
|
1297
|
+
// A model now carries the provider that serves it, so choosing a model
|
|
1298
|
+
// chooses a provider — which is how people actually think about it.
|
|
1299
|
+
const forProject = url.searchParams.get('projectId');
|
|
1300
|
+
const project = forProject ? await store.getProject(forProject) : null;
|
|
1301
|
+
json(res, 200, await availableModels(project));
|
|
1302
|
+
|
|
1303
|
+
} else if (req.method === 'GET' && url.pathname === '/projects') {
|
|
1304
|
+
const [projects, allRuns, auth] = await Promise.all([
|
|
1305
|
+
store.listProjects(), store.listRuns(), authPromise,
|
|
1306
|
+
]);
|
|
1307
|
+
const cards = await Promise.all(projects.map(async (p) => {
|
|
1308
|
+
const run = activeByProject.get(p.id);
|
|
1309
|
+
const plannerAsk = pendingChatQuestion(p.id);
|
|
1310
|
+
// Newest finished run for the idle-card summary (runs are newest-first).
|
|
1311
|
+
const lastRun = allRuns.find((r) => r.projectId === p.id && r.status !== 'running') ?? null;
|
|
1312
|
+
return {
|
|
1313
|
+
...p,
|
|
1314
|
+
// What THIS project will actually bill, which can differ from the
|
|
1315
|
+
// server's mode when the pin opts out of the inherited key.
|
|
1316
|
+
billingMode: await projectBilling(p, auth.mode),
|
|
1317
|
+
// Whether a key is on file, never the key. Settings renders
|
|
1318
|
+
// "stored / not stored" from this and nothing more.
|
|
1319
|
+
providerHasKey: await providerHasKeyOf(p),
|
|
1320
|
+
activeRun: run ? { ...run.meta } : null,
|
|
1321
|
+
lastRun: lastRun && {
|
|
1322
|
+
mission: lastRun.mission, title: lastRun.title, status: lastRun.status,
|
|
1323
|
+
createdAt: lastRun.createdAt, costUsd: lastRun.costUsd,
|
|
1324
|
+
// The card may print a dollar only where the dollar was real.
|
|
1325
|
+
costBasis: costBasisOf(lastRun), usage: lastRun.usage,
|
|
1326
|
+
},
|
|
1327
|
+
// When this project last did anything, so the fleet can lead with it.
|
|
1328
|
+
// A planner parked on a question is doing something — waiting on
|
|
1329
|
+
// you — and a never-run project falls back to when it was linked.
|
|
1330
|
+
lastActivityAt:
|
|
1331
|
+
plannerAsk?.askedAt ?? run?.meta.createdAt ?? lastRun?.endedAt ?? lastRun?.createdAt ?? p.createdAt,
|
|
1332
|
+
pendingPermissions: run?.pendingPermissionIds.length ?? 0,
|
|
1333
|
+
// A planner question blocks the human exactly as a director's does,
|
|
1334
|
+
// so it counts here: the card floats to the top tier and wears the
|
|
1335
|
+
// strip. `plannerQuestion` lets the strip say which one it is.
|
|
1336
|
+
pendingQuestions: (run?.pendingQuestionIds.length ?? 0) + (plannerAsk ? 1 : 0),
|
|
1337
|
+
plannerQuestion: Boolean(plannerAsk),
|
|
1338
|
+
// Everything blocking on the human, with enough to answer it from
|
|
1339
|
+
// the board: the same ids the tab's cards resolve, so a click here
|
|
1340
|
+
// and a click there are the same call.
|
|
1341
|
+
needs: [
|
|
1342
|
+
...(run?.pendingAsks() ?? []).map((a) => ({
|
|
1343
|
+
kind: a.kind, id: a.id, runId: run!.meta.id, text: a.text,
|
|
1344
|
+
options: a.options, toolName: a.toolName, since: a.since,
|
|
1345
|
+
})),
|
|
1346
|
+
...(plannerAsk ? [{
|
|
1347
|
+
kind: 'planner' as const, id: plannerAsk.id,
|
|
1348
|
+
text: plannerAsk.questions[0]?.question ?? 'The planner is asking',
|
|
1349
|
+
options: plannerAsk.questions[0]?.options.map((o) => o.label),
|
|
1350
|
+
since: plannerAsk.askedAt,
|
|
1351
|
+
}] : []),
|
|
1352
|
+
],
|
|
1353
|
+
};
|
|
1354
|
+
}));
|
|
1355
|
+
json(res, 200, {
|
|
1356
|
+
// Billing mode travels with every fleet poll so the UI can state it
|
|
1357
|
+
// plainly wherever money is about to be spent.
|
|
1358
|
+
authMode: auth.mode,
|
|
1359
|
+
authSource: auth.source,
|
|
1360
|
+
authAccount: auth.account ?? null,
|
|
1361
|
+
projects: cards.sort(fleetOrder),
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
} else if (req.method === 'POST' && url.pathname === '/projects') {
|
|
1365
|
+
const { folder, name, provider: providerIn, claudeConfigDir, claudeExecutable } = await readBody(req);
|
|
1366
|
+
const parsed = parseProvider(providerIn);
|
|
1367
|
+
if (typeof parsed === 'string') return json(res, 400, { error: parsed });
|
|
1368
|
+
if (typeof folder !== 'string' || !folder) return json(res, 400, { error: 'folder is required' });
|
|
1369
|
+
if (!path.isAbsolute(folder)) return json(res, 400, { error: `folder must be an absolute path: ${folder}` });
|
|
1370
|
+
const st = await stat(folder).catch(() => null);
|
|
1371
|
+
if (st && !st.isDirectory()) return json(res, 400, { error: `not a directory: ${folder}` });
|
|
1372
|
+
if (!st) await mkdir(folder, { recursive: true });
|
|
1373
|
+
// The HTTP shape still speaks "Claude Code install"; storage speaks
|
|
1374
|
+
// providers. Translating here keeps the UI working unchanged while the
|
|
1375
|
+
// union becomes the only thing written to disk.
|
|
1376
|
+
// A provider wins; the older claudeConfigDir/claudeExecutable pair still
|
|
1377
|
+
// works and means the same thing, so existing callers keep functioning.
|
|
1378
|
+
const pin: ProviderRef | undefined = parsed ?? (
|
|
1379
|
+
typeof claudeConfigDir === 'string' || typeof claudeExecutable === 'string'
|
|
1380
|
+
? {
|
|
1381
|
+
kind: 'claude-code',
|
|
1382
|
+
...(typeof claudeConfigDir === 'string' ? { configDir: claudeConfigDir } : {}),
|
|
1383
|
+
...(typeof claudeExecutable === 'string' ? { executable: claudeExecutable } : {}),
|
|
1384
|
+
}
|
|
1385
|
+
: undefined);
|
|
1386
|
+
json(res, 200, {
|
|
1387
|
+
project: await store.addProject(folder, typeof name === 'string' ? name : undefined, pin),
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
} else if (req.method === 'PATCH' && projectMatch) {
|
|
1391
|
+
const { name, defaultBudgetUsd, provider: providerIn, claudeConfigDir, claudeExecutable, claudeBilling } =
|
|
1392
|
+
await readBody(req);
|
|
1393
|
+
const parsedPatch = parseProvider(providerIn);
|
|
1394
|
+
if (typeof parsedPatch === 'string') return json(res, 400, { error: parsedPatch });
|
|
1395
|
+
// `provider: null` clears the pin outright; the legacy triple below
|
|
1396
|
+
// expresses the same thing by going empty.
|
|
1397
|
+
const providerCleared = providerIn === null;
|
|
1398
|
+
// null clears the pin; undefined leaves it untouched.
|
|
1399
|
+
const pinGiven =
|
|
1400
|
+
claudeConfigDir !== undefined || claudeExecutable !== undefined || claudeBilling !== undefined;
|
|
1401
|
+
const ownLogin = claudeBilling === 'own-login';
|
|
1402
|
+
// A billing choice is itself a pin, so clearing needs all three empty.
|
|
1403
|
+
const cleared =
|
|
1404
|
+
pinGiven && !toPath(claudeConfigDir) && !toPath(claudeExecutable) && !ownLogin;
|
|
1405
|
+
const updated = await store.updateProject(projectMatch[1], {
|
|
1406
|
+
name: typeof name === 'string' ? name : undefined,
|
|
1407
|
+
defaultBudgetUsd: typeof defaultBudgetUsd === 'number' ? defaultBudgetUsd : undefined,
|
|
1408
|
+
provider: parsedPatch ?? (providerCleared
|
|
1409
|
+
? null
|
|
1410
|
+
: !pinGiven
|
|
1411
|
+
? undefined
|
|
1412
|
+
: cleared
|
|
1413
|
+
? null
|
|
1414
|
+
: {
|
|
1415
|
+
kind: 'claude-code' as const,
|
|
1416
|
+
...(toPath(claudeConfigDir) ? { configDir: toPath(claudeConfigDir)! } : {}),
|
|
1417
|
+
...(toPath(claudeExecutable) ? { executable: toPath(claudeExecutable)! } : {}),
|
|
1418
|
+
...(ownLogin ? { ownLogin: true } : {}),
|
|
1419
|
+
}),
|
|
1420
|
+
});
|
|
1421
|
+
json(res, updated ? 200 : 404, updated ? { project: updated } : { error: 'unknown project' });
|
|
1422
|
+
|
|
1423
|
+
} else if (req.method === 'GET' && url.pathname === '/instances') {
|
|
1424
|
+
const [discovered, auth, keychain] = await Promise.all([
|
|
1425
|
+
discoverInstances(dirHasCredentials),
|
|
1426
|
+
detectAuth(),
|
|
1427
|
+
hasKeychainCredentials(),
|
|
1428
|
+
]);
|
|
1429
|
+
// Which account each dir is signed in as — the thing that actually
|
|
1430
|
+
// distinguishes one install from another.
|
|
1431
|
+
const instances = await Promise.all(discovered.map(async (i) => ({
|
|
1432
|
+
...i, account: await readAccount(i.configDir),
|
|
1433
|
+
})));
|
|
1434
|
+
json(res, 200, {
|
|
1435
|
+
serverDefault: defaultInstance(),
|
|
1436
|
+
// Auth is process-wide: an API key in the environment outranks every
|
|
1437
|
+
// stored login, so the picker must not imply the choice is per-dir.
|
|
1438
|
+
authMode: auth.mode,
|
|
1439
|
+
authAccount: auth.account ?? null,
|
|
1440
|
+
keychainLogin: keychain,
|
|
1441
|
+
// Gateways currently up, so "what is Foreman actually using" is one
|
|
1442
|
+
// request rather than a guess. Routes only — never a credential.
|
|
1443
|
+
gateways: gatewayStatus(),
|
|
1444
|
+
// A running local Ollama is offered with no configuration at all; the
|
|
1445
|
+
// absence of this key is what "none detected" looks like.
|
|
1446
|
+
ollama: await discoverOllama().then((models) =>
|
|
1447
|
+
models ? { host: ollamaHost(), models } : null),
|
|
1448
|
+
// Presence and sign-in state only — never the token.
|
|
1449
|
+
codex: await (async () => {
|
|
1450
|
+
const home = codexHome();
|
|
1451
|
+
const auth = await readCodexAuth(home).catch(() => null);
|
|
1452
|
+
const installed = await stat(path.join(home, 'auth.json')).then(() => true, () => false)
|
|
1453
|
+
|| await stat(path.join(home, 'config.toml')).then(() => true, () => false);
|
|
1454
|
+
return installed ? { home, signedIn: Boolean(auth) } : null;
|
|
1455
|
+
})(),
|
|
1456
|
+
instances,
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
} else if (req.method === 'DELETE' && projectMatch) {
|
|
1460
|
+
if (activeByProject.has(projectMatch[1])) {
|
|
1461
|
+
return json(res, 409, { error: 'project has an active mission' });
|
|
1462
|
+
}
|
|
1463
|
+
const removed = await store.removeProject(projectMatch[1]);
|
|
1464
|
+
json(res, removed ? 200 : 404, removed ? { ok: true } : { error: 'unknown project' });
|
|
1465
|
+
|
|
1466
|
+
} else if (req.method === 'POST' && url.pathname === '/run') {
|
|
1467
|
+
const {
|
|
1468
|
+
projectId, mission, budgetUsd, directorModel, workerModel, browserTools,
|
|
1469
|
+
directorProviderId, workerProviderId,
|
|
1470
|
+
} = await readBody(req);
|
|
1471
|
+
if (typeof projectId !== 'string' || typeof mission !== 'string' || !mission.trim()) {
|
|
1472
|
+
return json(res, 400, { error: 'projectId and mission are required' });
|
|
1473
|
+
}
|
|
1474
|
+
const project = await store.getProject(projectId);
|
|
1475
|
+
if (!project) return json(res, 404, { error: 'unknown project' });
|
|
1476
|
+
await mkdir(project.folder, { recursive: true });
|
|
1477
|
+
// Reservation is the last step before dispatch — no awaits in between.
|
|
1478
|
+
if (!reserveProject(projectId)) {
|
|
1479
|
+
return json(res, 409, { error: 'this project already has an active mission' });
|
|
1480
|
+
}
|
|
1481
|
+
const budget = Number(budgetUsd) > 0 ? Number(budgetUsd) : project.defaultBudgetUsd;
|
|
1482
|
+
void startRun(projectId, project.folder, mission, budget,
|
|
1483
|
+
modelChoice(directorModel), modelChoice(workerModel), browserTools === true,
|
|
1484
|
+
providerOf(project),
|
|
1485
|
+
{
|
|
1486
|
+
director: typeof directorProviderId === 'string' ? directorProviderId : undefined,
|
|
1487
|
+
worker: typeof workerProviderId === 'string' ? workerProviderId : undefined,
|
|
1488
|
+
});
|
|
1489
|
+
json(res, 200, { ok: true });
|
|
1490
|
+
|
|
1491
|
+
} else if (url.pathname === '/notify' && req.method === 'GET') {
|
|
1492
|
+
// Status, never the token. Mirrors the provider-key rule: the API says
|
|
1493
|
+
// whether a token exists and who is linked, and cannot read either out.
|
|
1494
|
+
const s = await notifySettings();
|
|
1495
|
+
json(res, 200, {
|
|
1496
|
+
publicUrl: s.publicUrl,
|
|
1497
|
+
prefs: s.prefs,
|
|
1498
|
+
active: notifyHub.active,
|
|
1499
|
+
delivered: notifyHub.delivered,
|
|
1500
|
+
failures: notifyHub.failures,
|
|
1501
|
+
telegram: {
|
|
1502
|
+
hasToken: await hasSecret(store.root, 'telegram'),
|
|
1503
|
+
bot: s.telegramBot ?? null,
|
|
1504
|
+
chatId: s.telegramChatId ? `…${s.telegramChatId.slice(-4)}` : null,
|
|
1505
|
+
chatLabel: s.telegramChatLabel ?? null,
|
|
1506
|
+
linking: telegramLink ? {
|
|
1507
|
+
code: telegramLink.code, startedAt: telegramLink.startedAt,
|
|
1508
|
+
// Opens the bot with /start <code> pre-filled: the QR below and
|
|
1509
|
+
// the "Open in Telegram" link both carry this.
|
|
1510
|
+
deepLink: s.telegramBot ? telegramStartLink(s.telegramBot, telegramLink.code) : undefined,
|
|
1511
|
+
} : null,
|
|
1512
|
+
},
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
} else if (url.pathname === '/notify/telegram/qr.svg' && req.method === 'GET') {
|
|
1516
|
+
// The deep link as a QR, only while a linking attempt is open — the code
|
|
1517
|
+
// is single-use and expires, so there is nothing to render otherwise.
|
|
1518
|
+
const s = await notifySettings();
|
|
1519
|
+
if (!telegramLink || !s.telegramBot) return json(res, 404, { error: 'no linking attempt in progress' });
|
|
1520
|
+
const svg = await QRCode.toString(telegramStartLink(s.telegramBot, telegramLink.code), {
|
|
1521
|
+
type: 'svg', margin: 1, errorCorrectionLevel: 'M',
|
|
1522
|
+
});
|
|
1523
|
+
res.writeHead(200, { 'content-type': 'image/svg+xml', 'cache-control': 'no-store' });
|
|
1524
|
+
res.end(svg);
|
|
1525
|
+
|
|
1526
|
+
} else if (url.pathname === '/notify/telegram/token') {
|
|
1527
|
+
// Write-only, like a provider key. Validated with getMe so a typo is
|
|
1528
|
+
// caught here rather than as a silently dead channel later.
|
|
1529
|
+
if (req.method === 'PUT') {
|
|
1530
|
+
const { token } = await readBody(req);
|
|
1531
|
+
const value = typeof token === 'string' ? token.trim() : '';
|
|
1532
|
+
if (!/^\d+:[A-Za-z0-9_-]{20,}$/.test(value)) return json(res, 400, { error: 'that does not look like a bot token' });
|
|
1533
|
+
const me = await getMe(value);
|
|
1534
|
+
if (!me) return json(res, 400, { error: 'Telegram rejected the token (or is unreachable)' });
|
|
1535
|
+
await putSecret(store.root, 'telegram', value);
|
|
1536
|
+
await patchGlobalSettings({ telegramBot: `@${me.username}` });
|
|
1537
|
+
await reattachTelegram();
|
|
1538
|
+
json(res, 200, { ok: true, bot: `@${me.username}` });
|
|
1539
|
+
} else if (req.method === 'DELETE') {
|
|
1540
|
+
telegramLink?.abort(); telegramLink = null;
|
|
1541
|
+
await deleteSecret(store.root, 'telegram');
|
|
1542
|
+
await patchGlobalSettings({ telegramBot: undefined, telegramChatId: undefined, telegramChatLabel: undefined });
|
|
1543
|
+
notifyHub.detach('telegram');
|
|
1544
|
+
void telegramBot?.stop(); telegramBot = null;
|
|
1545
|
+
json(res, 200, { ok: true });
|
|
1546
|
+
} else {
|
|
1547
|
+
json(res, 405, { error: 'method not allowed' });
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
} else if (url.pathname === '/notify/telegram/link') {
|
|
1551
|
+
if (req.method === 'POST') {
|
|
1552
|
+
const token = await getSecret(store.root, 'telegram');
|
|
1553
|
+
if (!token) return json(res, 409, { error: 'store a bot token first' });
|
|
1554
|
+
if (!telegramBot) await reattachTelegram();
|
|
1555
|
+
if (!telegramBot) return json(res, 409, { error: 'could not start the Telegram reader' });
|
|
1556
|
+
telegramLink?.abort();
|
|
1557
|
+
const code = linkCode();
|
|
1558
|
+
const link = telegramBot.link(code);
|
|
1559
|
+
telegramLink = { code, abort: link.abort, startedAt: Date.now() };
|
|
1560
|
+
// Resolves in the background; the UI polls GET /notify for the result.
|
|
1561
|
+
void link.done.then(async (chat) => {
|
|
1562
|
+
if (telegramLink?.code === code) telegramLink = null;
|
|
1563
|
+
if (!chat) return;
|
|
1564
|
+
await patchGlobalSettings({ telegramChatId: chat.chatId, telegramChatLabel: chat.label });
|
|
1565
|
+
if (await reattachTelegram()) {
|
|
1566
|
+
void notifyHub.say('<b>Foreman linked.</b> Approvals, questions and finished missions will arrive here.');
|
|
1567
|
+
}
|
|
1568
|
+
});
|
|
1569
|
+
json(res, 200, { ok: true, code, command: `/start ${code}` });
|
|
1570
|
+
} else if (req.method === 'DELETE') {
|
|
1571
|
+
telegramLink?.abort(); telegramLink = null;
|
|
1572
|
+
await patchGlobalSettings({ telegramChatId: undefined, telegramChatLabel: undefined });
|
|
1573
|
+
notifyHub.detach('telegram');
|
|
1574
|
+
json(res, 200, { ok: true });
|
|
1575
|
+
} else {
|
|
1576
|
+
json(res, 405, { error: 'method not allowed' });
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
} else if (url.pathname === '/notify/test' && req.method === 'POST') {
|
|
1580
|
+
const ok = await notifyHub.say('<b>Test from Foreman.</b> This is where you will hear about approvals, stalls and finished missions.');
|
|
1581
|
+
json(res, ok ? 200 : 409, ok ? { ok: true } : { error: 'no linked channel, or delivery failed' });
|
|
1582
|
+
|
|
1583
|
+
} else if (url.pathname === '/notify/settings' && req.method === 'PATCH') {
|
|
1584
|
+
// The one setting that lives here rather than in the general Settings
|
|
1585
|
+
// payload: where deep links point. A phone cannot open localhost.
|
|
1586
|
+
const { publicUrl } = await readBody(req);
|
|
1587
|
+
if (typeof publicUrl !== 'string') return json(res, 400, { error: 'publicUrl is required' });
|
|
1588
|
+
const v = publicUrl.trim().replace(/\/+$/, '');
|
|
1589
|
+
if (v && !/^https?:\/\/[^\s/]+/.test(v)) return json(res, 400, { error: 'publicUrl must be an http(s) URL' });
|
|
1590
|
+
await patchGlobalSettings({ publicUrl: v || undefined });
|
|
1591
|
+
json(res, 200, { ok: true, publicUrl: v || `http://localhost:${PORT}` });
|
|
1592
|
+
|
|
1593
|
+
} else if (providerKeyMatch) {
|
|
1594
|
+
// Write-only by design: there is no GET. The API can say whether a key
|
|
1595
|
+
// exists — which Settings needs to render its state — and never what it
|
|
1596
|
+
// is, so a compromised browser session can replace a key but not read
|
|
1597
|
+
// one out.
|
|
1598
|
+
const providerId = providerKeyMatch[1];
|
|
1599
|
+
if (req.method === 'PUT') {
|
|
1600
|
+
const { key } = await readBody(req);
|
|
1601
|
+
const value = typeof key === 'string' ? key.trim() : '';
|
|
1602
|
+
if (!value) return json(res, 400, { error: 'key is required' });
|
|
1603
|
+
await putSecret(store.root, providerId, value);
|
|
1604
|
+
json(res, 200, { ok: true, hasKey: true });
|
|
1605
|
+
} else if (req.method === 'DELETE') {
|
|
1606
|
+
await deleteSecret(store.root, providerId);
|
|
1607
|
+
json(res, 200, { ok: true, hasKey: false });
|
|
1608
|
+
} else {
|
|
1609
|
+
json(res, 405, { error: 'method not allowed' });
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
} else if (url.pathname === '/chat') {
|
|
1613
|
+
const projectId = req.method === 'POST'
|
|
1614
|
+
? undefined : url.searchParams.get('projectId') ?? '';
|
|
1615
|
+
|
|
1616
|
+
if (req.method === 'GET') {
|
|
1617
|
+
if (!projectId) return json(res, 400, { error: 'projectId is required' });
|
|
1618
|
+
const [meta, events] = await Promise.all([
|
|
1619
|
+
chatMetaOf(projectId),
|
|
1620
|
+
store.readChatEvents(projectId).catch(() => []),
|
|
1621
|
+
]);
|
|
1622
|
+
// Who answers here, for the bar's footer before any turn has run.
|
|
1623
|
+
// Best-effort: a project whose provider cannot resolve still gets its
|
|
1624
|
+
// transcript, and the first turn will say what went wrong.
|
|
1625
|
+
const project = await store.getProject(projectId);
|
|
1626
|
+
const settings = await effectiveSettings(projectId).catch(() => null);
|
|
1627
|
+
const resolved = project
|
|
1628
|
+
? await resolveProvider(providerOf(project), store.root).catch(() => null) : null;
|
|
1629
|
+
json(res, 200, {
|
|
1630
|
+
events,
|
|
1631
|
+
costUsd: meta.costUsd,
|
|
1632
|
+
proposal: meta.proposal ?? null,
|
|
1633
|
+
// A turn in flight is server state, not log state: a client that
|
|
1634
|
+
// loads mid-turn needs to know a reply is already on its way.
|
|
1635
|
+
thinking: chatTurns.has(projectId),
|
|
1636
|
+
// Likewise a question the planner is parked on — it lives in the
|
|
1637
|
+
// turn, not the log, and a reload must put the picker back.
|
|
1638
|
+
question: pendingChatQuestion(projectId),
|
|
1639
|
+
who: resolved ? {
|
|
1640
|
+
model: settings?.plannerModel || DEFAULT_PLANNER_MODEL,
|
|
1641
|
+
provider: resolved.label,
|
|
1642
|
+
costBasis: resolved.costBasis,
|
|
1643
|
+
} : null,
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
} else if (req.method === 'DELETE') {
|
|
1647
|
+
if (!projectId) return json(res, 400, { error: 'projectId is required' });
|
|
1648
|
+
if (chatTurns.has(projectId)) {
|
|
1649
|
+
return json(res, 409, { error: 'the planner is mid-reply — wait for it to finish' });
|
|
1650
|
+
}
|
|
1651
|
+
await store.clearChat(projectId).catch(() => {});
|
|
1652
|
+
broadcastChat(projectId, 'chat_cleared', {});
|
|
1653
|
+
json(res, 200, { ok: true });
|
|
1654
|
+
|
|
1655
|
+
} else if (req.method === 'POST') {
|
|
1656
|
+
const { projectId: id, text } = await readBody(req);
|
|
1657
|
+
const message = typeof text === 'string' ? text.trim() : '';
|
|
1658
|
+
if (typeof id !== 'string' || !message) {
|
|
1659
|
+
return json(res, 400, { error: 'projectId and text are required' });
|
|
1660
|
+
}
|
|
1661
|
+
const project = await store.getProject(id);
|
|
1662
|
+
if (!project) return json(res, 404, { error: 'unknown project' });
|
|
1663
|
+
// While a mission runs, the director is who you talk to — the same
|
|
1664
|
+
// input box becomes the steer bar. Planning stays an idle-only act,
|
|
1665
|
+
// which is what keeps "one active mission per project" honest.
|
|
1666
|
+
if (activeByProject.has(id)) {
|
|
1667
|
+
return json(res, 409, { error: 'this project has a mission running — steer the director instead' });
|
|
1668
|
+
}
|
|
1669
|
+
// A turn parked on a question is still a turn — but a human who types
|
|
1670
|
+
// instead of clicking is answering, not starting a new message. Route
|
|
1671
|
+
// the text to the waiting question rather than refusing it: the
|
|
1672
|
+
// picker's "something else" and the plain input box should mean the
|
|
1673
|
+
// same thing.
|
|
1674
|
+
const pending = pendingChatQuestion(id);
|
|
1675
|
+
if (pending) {
|
|
1676
|
+
const first = pending.questions[0]?.question ?? 'answer';
|
|
1677
|
+
const emit = makeChatEmitter(id);
|
|
1678
|
+
emit('chat_message', { text: message });
|
|
1679
|
+
if (answerChatQuestion(id, pending.id, { [first]: message })) {
|
|
1680
|
+
emit('chat_answered', { id: pending.id, answers: { [first]: message } });
|
|
1681
|
+
}
|
|
1682
|
+
return json(res, 200, { ok: true, answered: pending.id });
|
|
1683
|
+
}
|
|
1684
|
+
// Check-and-set with no await in between, like reserveProject.
|
|
1685
|
+
if (chatTurns.has(id)) return json(res, 409, { error: 'the planner is still replying' });
|
|
1686
|
+
chatTurns.add(id);
|
|
1687
|
+
void driveChatTurn(project, message);
|
|
1688
|
+
json(res, 200, { ok: true });
|
|
1689
|
+
|
|
1690
|
+
} else {
|
|
1691
|
+
json(res, 405, { error: 'method not allowed' });
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
} else if (req.method === 'POST' && url.pathname === '/chat/stop') {
|
|
1695
|
+
// Stop the reply in flight. The model call is aborted, a question the
|
|
1696
|
+
// planner was parked on is dropped, and the turn closes on the record
|
|
1697
|
+
// with a line saying it was stopped. The conversation stays usable.
|
|
1698
|
+
const { projectId: id } = await readBody(req);
|
|
1699
|
+
if (typeof id !== 'string') return json(res, 400, { error: 'projectId is required' });
|
|
1700
|
+
const abort = chatAborts.get(id);
|
|
1701
|
+
if (!abort) return json(res, 200, { ok: true, stopped: false });
|
|
1702
|
+
dropPendingAsk(id);
|
|
1703
|
+
abort.abort();
|
|
1704
|
+
json(res, 200, { ok: true, stopped: true });
|
|
1705
|
+
|
|
1706
|
+
} else if (req.method === 'POST' && url.pathname === '/attachments') {
|
|
1707
|
+
// Files for a message — to the planner or in a mission brief. Saved
|
|
1708
|
+
// into the project folder so whoever reads the message can read them
|
|
1709
|
+
// too; the client appends the returned paths to its text.
|
|
1710
|
+
const { projectId: id, files } = await readBody(req);
|
|
1711
|
+
if (typeof id !== 'string') return json(res, 400, { error: 'projectId is required' });
|
|
1712
|
+
const project = await store.getProject(id);
|
|
1713
|
+
if (!project) return json(res, 404, { error: 'unknown project' });
|
|
1714
|
+
try {
|
|
1715
|
+
const saved = await saveAttachments(project.folder, files as Parameters<typeof saveAttachments>[1]);
|
|
1716
|
+
json(res, 200, { files: saved });
|
|
1717
|
+
} catch (err) {
|
|
1718
|
+
json(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
} else if (req.method === 'POST' && url.pathname === '/chat/fork') {
|
|
1722
|
+
// "Plan the next step": a new planning conversation seeded with a
|
|
1723
|
+
// finished run — its brief, its mission doc, the director's report.
|
|
1724
|
+
// A fork, not a continuation: the old run stays what it was, and what
|
|
1725
|
+
// comes out is a fresh mission with its own budget and baseline.
|
|
1726
|
+
const { projectId: id, runId } = await readBody(req);
|
|
1727
|
+
if (typeof id !== 'string' || typeof runId !== 'string') {
|
|
1728
|
+
return json(res, 400, { error: 'projectId and runId are required' });
|
|
1729
|
+
}
|
|
1730
|
+
const project = await store.getProject(id);
|
|
1731
|
+
if (!project) return json(res, 404, { error: 'unknown project' });
|
|
1732
|
+
const meta = await store.readMeta(runId);
|
|
1733
|
+
if (!meta || meta.folder !== project.folder) return json(res, 404, { error: 'unknown run for this project' });
|
|
1734
|
+
if (meta.status === 'running' || activeByProject.has(id)) {
|
|
1735
|
+
return json(res, 409, { error: 'that mission is still running — plan its next step once it has ended' });
|
|
1736
|
+
}
|
|
1737
|
+
if (chatTurns.has(id)) return json(res, 409, { error: 'the planner is mid-reply — wait for it to finish' });
|
|
1738
|
+
const [missionDoc, events] = await Promise.all([
|
|
1739
|
+
readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null),
|
|
1740
|
+
store.readEvents(runId).catch(() => []),
|
|
1741
|
+
]);
|
|
1742
|
+
// The director's closing words: the last SDK result message it produced.
|
|
1743
|
+
let report: string | null = null;
|
|
1744
|
+
for (const e of events) {
|
|
1745
|
+
const d = e.data as { agent?: string; msg?: { type?: string; result?: unknown } } | undefined;
|
|
1746
|
+
if (e.event === 'message' && d?.agent === 'director' && d.msg?.type === 'result' && typeof d.msg.result === 'string') {
|
|
1747
|
+
report = d.msg.result;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
const seed = forkSeed({
|
|
1751
|
+
title: meta.title, mission: meta.mission, status: meta.status, endedAt: meta.endedAt,
|
|
1752
|
+
missionDoc, report,
|
|
1753
|
+
});
|
|
1754
|
+
// The seed opens a new conversation. Folding it into an old session
|
|
1755
|
+
// would hand the planner two contexts at once; the transcript keeps
|
|
1756
|
+
// the fork's own opening line as the human's message.
|
|
1757
|
+
chatTurns.add(id);
|
|
1758
|
+
await store.clearChat(id).catch(() => {});
|
|
1759
|
+
// Every open tab drops the old conversation — proposal card included —
|
|
1760
|
+
// before the seeded one starts arriving. Without this a second fork
|
|
1761
|
+
// left the previous proposal on screen above the new question.
|
|
1762
|
+
broadcastChat(id, 'chat_cleared', {});
|
|
1763
|
+
void driveChatTurn(project, seed.prompt, seed.shown);
|
|
1764
|
+
json(res, 200, { ok: true });
|
|
1765
|
+
|
|
1766
|
+
} else if (req.method === 'POST' && url.pathname === '/chat/answer') {
|
|
1767
|
+
// The picker's answer to a planner ask_user. Resolves the tool call that
|
|
1768
|
+
// is blocking the turn; the transcript records what was chosen so a
|
|
1769
|
+
// reload shows the decision, not just the question.
|
|
1770
|
+
const { projectId: id, id: questionId, answers } = await readBody(req);
|
|
1771
|
+
if (typeof id !== 'string' || typeof questionId !== 'string' || !answers || typeof answers !== 'object') {
|
|
1772
|
+
return json(res, 400, { error: 'projectId, id and answers are required' });
|
|
1773
|
+
}
|
|
1774
|
+
const clean: Record<string, string> = {};
|
|
1775
|
+
for (const [k, v] of Object.entries(answers as Record<string, unknown>)) {
|
|
1776
|
+
if (typeof v === 'string') clean[k] = v.slice(0, 2000);
|
|
1777
|
+
}
|
|
1778
|
+
if (!answerChatQuestion(id, questionId, clean)) {
|
|
1779
|
+
return json(res, 404, { error: 'no question waiting under that id — it may have timed out' });
|
|
1780
|
+
}
|
|
1781
|
+
makeChatEmitter(id)('chat_answered', { id: questionId, answers: clean });
|
|
1782
|
+
json(res, 200, { ok: true });
|
|
1783
|
+
|
|
1784
|
+
} else if (req.method === 'POST' && url.pathname === '/permission') {
|
|
1785
|
+
const { id, behavior, message } = await readBody(req);
|
|
1786
|
+
const valid = behavior === 'allow' || behavior === 'allow_always' || behavior === 'deny';
|
|
1787
|
+
if (typeof id !== 'string' || !valid) return json(res, 400, { error: 'invalid request' });
|
|
1788
|
+
// Approval ids are globally unique (tool-use ids); find the owning run.
|
|
1789
|
+
const ok = activeRuns().some((r) =>
|
|
1790
|
+
r.resolvePermission(id, behavior, message as string | undefined));
|
|
1791
|
+
if (!ok) return json(res, 404, { error: 'no pending permission with that id' });
|
|
1792
|
+
json(res, 200, { ok: true });
|
|
1793
|
+
|
|
1794
|
+
} else if (req.method === 'POST' && url.pathname === '/answer') {
|
|
1795
|
+
const { id, text } = await readBody(req);
|
|
1796
|
+
if (typeof id !== 'string') return json(res, 400, { error: 'invalid request' });
|
|
1797
|
+
const ok = activeRuns().some((r) => r.answerQuestion(id, String(text ?? '')));
|
|
1798
|
+
if (!ok) return json(res, 404, { error: 'no pending question with that id' });
|
|
1799
|
+
json(res, 200, { ok: true });
|
|
1800
|
+
|
|
1801
|
+
} else if (req.method === 'POST' && url.pathname === '/steer') {
|
|
1802
|
+
const { runId, text } = await readBody(req);
|
|
1803
|
+
const trimmed = typeof text === 'string' ? text.trim() : '';
|
|
1804
|
+
if (typeof runId !== 'string' || !trimmed) return json(res, 400, { error: 'invalid request' });
|
|
1805
|
+
const run = activeRuns().find((r) => r.meta.id === runId);
|
|
1806
|
+
if (!run) return json(res, 404, { error: 'no active run with that id' });
|
|
1807
|
+
if (!run.steer(trimmed)) return json(res, 409, { error: 'run is no longer accepting steers' });
|
|
1808
|
+
json(res, 200, { ok: true });
|
|
1809
|
+
|
|
1810
|
+
} else if (req.method === 'PATCH' && url.pathname === '/run') {
|
|
1811
|
+
// Change a live mission's settings. Deliberately only the two that
|
|
1812
|
+
// genuinely bind mid-run — see MissionRun.applySettings(). A field that
|
|
1813
|
+
// needs a restart belongs on the resume path, not here, because a
|
|
1814
|
+
// setting that silently does nothing until some later event is worse
|
|
1815
|
+
// than one the UI never offered.
|
|
1816
|
+
const { runId, browserTools, budgetUsd } = await readBody(req);
|
|
1817
|
+
const run = activeRuns().find((r) => r.meta.id === runId);
|
|
1818
|
+
if (!run) return json(res, 404, { error: 'no active run with that id' });
|
|
1819
|
+
const patch: { browserTools?: boolean; budgetUsd?: number } = {};
|
|
1820
|
+
if (typeof browserTools === 'boolean') patch.browserTools = browserTools;
|
|
1821
|
+
if (typeof budgetUsd === 'number' && Number.isFinite(budgetUsd) && budgetUsd >= 0) {
|
|
1822
|
+
patch.budgetUsd = budgetUsd;
|
|
1823
|
+
}
|
|
1824
|
+
if (!Object.keys(patch).length) return json(res, 400, { error: 'nothing to change' });
|
|
1825
|
+
const changes = run.applySettings(patch);
|
|
1826
|
+
await store.writeMeta(run.meta).catch(() => {});
|
|
1827
|
+
json(res, 200, { ok: true, changes });
|
|
1828
|
+
|
|
1829
|
+
} else if (req.method === 'POST' && url.pathname === '/interrupt') {
|
|
1830
|
+
const { runId } = await readBody(req);
|
|
1831
|
+
const run = activeRuns().find((r) => r.meta.id === runId);
|
|
1832
|
+
if (!run) return json(res, 404, { error: 'no active run with that id' });
|
|
1833
|
+
await run.interrupt();
|
|
1834
|
+
json(res, 200, { ok: true });
|
|
1835
|
+
|
|
1836
|
+
} else if (req.method === 'GET' && url.pathname === '/runs') {
|
|
1837
|
+
const projectId = url.searchParams.get('projectId');
|
|
1838
|
+
const runs = await store.listRuns();
|
|
1839
|
+
json(res, 200, { runs: projectId ? runs.filter((r) => r.projectId === projectId) : runs });
|
|
1840
|
+
|
|
1841
|
+
} else if (req.method === 'POST' && runResumeMatch) {
|
|
1842
|
+
const meta = await store.readMeta(runResumeMatch[1]).catch(() => null);
|
|
1843
|
+
if (!meta) return json(res, 404, { error: 'unknown run' });
|
|
1844
|
+
// Resume already re-reads Settings; an explicit patch rides along for
|
|
1845
|
+
// the things that are per-run rather than per-project. Browser tools
|
|
1846
|
+
// matter here specifically: PATCH /run reaches future workers, but the
|
|
1847
|
+
// director keeps the tool set its own query() opened with, so a resume
|
|
1848
|
+
// is the only point at which the DIRECTOR can gain a browser.
|
|
1849
|
+
const resumeBody: Record<string, unknown> = await readBody(req).catch(() => ({}));
|
|
1850
|
+
if (typeof resumeBody.browserTools === 'boolean') {
|
|
1851
|
+
meta.browserTools = resumeBody.browserTools || undefined;
|
|
1852
|
+
}
|
|
1853
|
+
if (meta.status === 'running' || meta.status === 'done') {
|
|
1854
|
+
return json(res, 409, { error: `run is ${meta.status}; only interrupted or failed runs can resume` });
|
|
1855
|
+
}
|
|
1856
|
+
if (!meta.directorSessionId) {
|
|
1857
|
+
return json(res, 409, { error: 'run has no director session to resume' });
|
|
1858
|
+
}
|
|
1859
|
+
if (!meta.projectId || !(await store.getProject(meta.projectId))) {
|
|
1860
|
+
return json(res, 409, { error: 'run has no linked project' });
|
|
1861
|
+
}
|
|
1862
|
+
// Reservation is the last step before dispatch — no awaits in between.
|
|
1863
|
+
if (!reserveProject(meta.projectId)) {
|
|
1864
|
+
return json(res, 409, { error: 'this project already has an active mission' });
|
|
1865
|
+
}
|
|
1866
|
+
void resumeRun(meta.projectId, meta);
|
|
1867
|
+
json(res, 200, { ok: true });
|
|
1868
|
+
|
|
1869
|
+
} else if (req.method === 'GET' && runEventsMatch) {
|
|
1870
|
+
const events = await store.readEvents(runEventsMatch[1]).catch(() => null);
|
|
1871
|
+
if (!events) return json(res, 404, { error: 'unknown run' });
|
|
1872
|
+
json(res, 200, { events });
|
|
1873
|
+
|
|
1874
|
+
} else if (req.method === 'GET' && url.pathname === '/missiondoc') {
|
|
1875
|
+
const runId = url.searchParams.get('run');
|
|
1876
|
+
if (!runId) return json(res, 400, { error: 'run parameter is required' });
|
|
1877
|
+
const meta = await store.readMeta(runId);
|
|
1878
|
+
if (!meta) return json(res, 404, { error: 'unknown run' });
|
|
1879
|
+
const doc = await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8')
|
|
1880
|
+
.catch(() => null);
|
|
1881
|
+
json(res, 200, { doc });
|
|
1882
|
+
|
|
1883
|
+
} else if (req.method === 'GET' && url.pathname === '/browse') {
|
|
1884
|
+
const requested = url.searchParams.get('path') || os.homedir();
|
|
1885
|
+
const dir = path.resolve(requested);
|
|
1886
|
+
const st = await stat(dir).catch(() => null);
|
|
1887
|
+
if (!st?.isDirectory()) return json(res, 400, { error: `not a directory: ${dir}` });
|
|
1888
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1889
|
+
const dirs = entries
|
|
1890
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
1891
|
+
.map((e) => e.name)
|
|
1892
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1893
|
+
const parent = path.dirname(dir);
|
|
1894
|
+
json(res, 200, { path: dir, parent: parent === dir ? null : parent, dirs });
|
|
1895
|
+
|
|
1896
|
+
} else if (req.method === 'POST' && url.pathname === '/mkdir') {
|
|
1897
|
+
const { parent, name } = await readBody(req);
|
|
1898
|
+
if (typeof parent !== 'string' || !path.isAbsolute(parent)) {
|
|
1899
|
+
return json(res, 400, { error: 'parent must be an absolute path' });
|
|
1900
|
+
}
|
|
1901
|
+
if (typeof name !== 'string' || !name.trim() || /[/\\]/.test(name) || name.trim().startsWith('.')) {
|
|
1902
|
+
return json(res, 400, { error: 'invalid folder name' });
|
|
1903
|
+
}
|
|
1904
|
+
const parentSt = await stat(parent).catch(() => null);
|
|
1905
|
+
if (!parentSt?.isDirectory()) return json(res, 400, { error: `not a directory: ${parent}` });
|
|
1906
|
+
const created = path.join(parent, name.trim());
|
|
1907
|
+
await mkdir(created, { recursive: true });
|
|
1908
|
+
json(res, 200, { path: created });
|
|
1909
|
+
|
|
1910
|
+
} else if (req.method === 'GET' && url.pathname === '/locate') {
|
|
1911
|
+
const name = (url.searchParams.get('name') ?? '').trim();
|
|
1912
|
+
if (!name) return json(res, 400, { error: 'name is required' });
|
|
1913
|
+
json(res, 200, { matches: await locateFolders(name) });
|
|
1914
|
+
|
|
1915
|
+
} else {
|
|
1916
|
+
json(res, 404, { error: 'not found' });
|
|
1917
|
+
}
|
|
1918
|
+
} catch (err) {
|
|
1919
|
+
json(res, 500, { error: String(err) });
|
|
1920
|
+
}
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
// Fail loudly on a misconfigured install before anything else happens.
|
|
1924
|
+
if (!reportPreflight(await preflight({ port: PORT, foremanHome: store.root, distDir: DIST_DIR, tailnet }))) {
|
|
1925
|
+
process.exit(1);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// Reconcile runs orphaned by a previous process before accepting traffic.
|
|
1929
|
+
const swept = await store.sweepOrphans();
|
|
1930
|
+
if (swept.length) console.log(`Marked ${swept.length} orphaned run(s) as interrupted:`, swept.join(', '));
|
|
1931
|
+
|
|
1932
|
+
// A stored token and a linked chat survive restarts; the channel comes back
|
|
1933
|
+
// with the server, without anyone re-linking.
|
|
1934
|
+
void reattachTelegram();
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* A planning turn that was in flight when the process died left its log
|
|
1938
|
+
* ending in "thinking" with no "idle" — and a client replaying that log
|
|
1939
|
+
* showed a planner still looking, with Clear waiting for a reply that would
|
|
1940
|
+
* never come. Close every such turn on the record, with a line saying why.
|
|
1941
|
+
*/
|
|
1942
|
+
async function closeOrphanedChatTurns(): Promise<string[]> {
|
|
1943
|
+
const closed: string[] = [];
|
|
1944
|
+
for (const id of await store.listChatIds()) {
|
|
1945
|
+
const events = await store.readChatEvents(id).catch(() => []);
|
|
1946
|
+
let open = false;
|
|
1947
|
+
for (const e of events) {
|
|
1948
|
+
if (e.event === 'chat_turn') open = (e.data as { state?: string })?.state === 'thinking';
|
|
1949
|
+
}
|
|
1950
|
+
if (!open) continue;
|
|
1951
|
+
const emit = makeChatEmitter(id);
|
|
1952
|
+
emit('chat_error', { error: 'Foreman restarted while the planner was replying — send your message again.' });
|
|
1953
|
+
emit('chat_turn', { state: 'idle' });
|
|
1954
|
+
closed.push(id);
|
|
1955
|
+
}
|
|
1956
|
+
return closed;
|
|
1957
|
+
}
|
|
1958
|
+
void closeOrphanedChatTurns().then((ids) => {
|
|
1959
|
+
if (ids.length) console.log(`Closed ${ids.length} planning turn(s) cut off by the last shutdown:`, ids.join(', '));
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
// Services declared by earlier runs are still worth proxying if their
|
|
1963
|
+
// processes outlived the run; the registry is rebuilt from what was saved.
|
|
1964
|
+
void store.listRuns().then((runs) => {
|
|
1965
|
+
for (const r of runs) for (const s of (r as { services?: Array<{ port: number; label: string }> }).services ?? []) services.register(r.id, s.port, s.label);
|
|
1966
|
+
}).catch(() => {});
|
|
1967
|
+
|
|
1968
|
+
if (BIND === 'all') {
|
|
1969
|
+
server.listen(PORT, () => console.log(`Foreman listening on http://0.0.0.0:${PORT} (FOREMAN_BIND=all)`));
|
|
1970
|
+
} else {
|
|
1971
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
1972
|
+
console.log(`Foreman listening on http://localhost:${PORT}${tailnet ? ` · ${tailnetUrl(tailnet, PORT)}` : ''}`);
|
|
1973
|
+
});
|
|
1974
|
+
if (tailnet) {
|
|
1975
|
+
// A second listener on the tailnet address, feeding the same handler.
|
|
1976
|
+
// Not 0.0.0.0: the café Wi-Fi is not the tailnet.
|
|
1977
|
+
const onRequest = server.listeners('request')[0] as http.RequestListener;
|
|
1978
|
+
const viaTailnet = http.createServer(onRequest);
|
|
1979
|
+
viaTailnet.on('error', (err) => console.warn(`[tailscale] could not listen on ${tailnet.ip}:${PORT} — ${err.message}`));
|
|
1980
|
+
viaTailnet.listen(PORT, tailnet.ip);
|
|
1981
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => viaTailnet.close());
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// Gateways are children of this process; a hard exit would orphan them holding
|
|
1986
|
+
// loopback ports. Both signals a terminal or a supervisor sends are handled.
|
|
1987
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
1988
|
+
process.on(signal, () => {
|
|
1989
|
+
stopGateways();
|
|
1990
|
+
process.exit(0);
|
|
1991
|
+
});
|
|
1992
|
+
}
|