@amenophis1er/foreman 0.1.6 → 0.1.8

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/src/server.ts CHANGED
@@ -24,9 +24,17 @@
24
24
  * POST /runs/{id}/resume Resume an interrupted/failed run
25
25
  * POST /permission Resolve an approval {id, behavior, message?}
26
26
  * POST /answer Answer a director question {id, text}
27
+ * POST /projects/clone Clone a Git URL under the projects root and link it {url, branch?} → {id}; GET /projects/clone/{id} polls
28
+ * GET /projects/{id}/memory The project's memory (.foreman/MEMORY.md): text, updatedAt
29
+ * GET /projects/{id}/tree The project's files as they stand (read-only, jailed); …/artifact and …/preview as for runs
30
+ * GET /doctor The same checks `foreman doctor` runs, for the first-run card
31
+ * GET /search?q= Runs across the fleet matching title, brief, project or folder
27
32
  * POST /fleet/chat One turn with the fleet planner {text} → {text, costUsd}
28
33
  * POST /fleet/stop Stop the fleet planner reply in flight
29
34
  * DELETE /fleet/chat Forget the fleet conversation
35
+ * GET /runs/{id}/pr The pull request Foreman would draft for a finished run on its own branch
36
+ * POST /runs/{id}/pr Push that branch and open the PR (gh) or hand back the compare URL {title, body}
37
+ * GET /runs/{id}/pr/state open | merged | closed, from gh; a final answer is remembered on the run
30
38
  * POST /steer Send an operator note to a running director {runId, text}
31
39
  * POST /interrupt Interrupt a run {runId}
32
40
  * GET /runs?projectId= Persisted run summaries, newest first
@@ -46,7 +54,7 @@
46
54
  import http from 'node:http';
47
55
  import crypto from 'node:crypto';
48
56
  import os from 'node:os';
49
- import { mkdir, readFile, readdir, stat } from 'node:fs/promises';
57
+ import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
50
58
  import path from 'node:path';
51
59
  import { fileURLToPath } from 'node:url';
52
60
  import { MissionRun } from './orchestrator.js';
@@ -56,10 +64,18 @@ import {
56
64
  dropPendingAsk,
57
65
  } from './planner.js';
58
66
  import { DEFAULT_TOOL_POLICY } from './policy.js';
67
+ import { pathPermitted, requestAllowed } from './guard.js';
68
+ import { BodyError, bodyLimitFor, parseBody } from './http-body.js';
59
69
  import { saveAttachments } from './attachments.js';
70
+ import { cloneRepo, looksLikeRepoUrl, parseRepoUrl } from './clone.js';
71
+ import { readMemory } from './memory.js';
72
+ import { budgetAnchor, modelRecords, projectRecord, recordLine } from './track-record.js';
73
+ import { reconcileRole } from './role-provider.js';
74
+ import { detectBrowser, installChromium } from './browser.js';
75
+ import { closeMissionBranch, compareUrl, createPullRequest, ensureMissionBranch, ghReady, gitInfo, prDraft, pullRequestState, pushBranch, startMissionBranch, type GitInfo } from './gitwork.js';
60
76
  import { detectTailscale, tailnetUrl } from './tailscale.js';
61
77
  import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
62
- import { ServiceRegistry, SVC_PREFIX, parseServicePath, portOpen, proxyToService, servicePath } from './services.js';
78
+ import { ServiceRegistry, portOpen, servicesHandler } from './services.js';
63
79
  import { HELP_TEXT, expandHome, parseCommand, projectsRoot, slug } from './notify/commands.js';
64
80
  import {
65
81
  DEFAULT_FLEET_MODEL, FLEET_CHAT_ID, PHONE_CONTEXT_MS, phoneRoute, runFleetTurn,
@@ -228,6 +244,8 @@ interface ModelOption {
228
244
  providerLabel: string;
229
245
  cost?: number;
230
246
  note?: string;
247
+ /** Its track record on this machine, from the run ledger; absent until it has one. */
248
+ record?: string;
231
249
  /** What spending on this model is: priced, free, or real-but-unquantified. */
232
250
  costBasis: CostBasis;
233
251
  /** @deprecated Mirrors `costBasis === 'priced'` for older clients. */
@@ -248,6 +266,15 @@ async function availableModels(project: Project | null): Promise<{
248
266
  reachable: boolean;
249
267
  }> {
250
268
  const out: ModelOption[] = [];
269
+ // The ledger, read back: each model's record across every run on this
270
+ // machine, attached as one line so the picker and the planner can weigh
271
+ // "cheap" against "finished last time".
272
+ const records = modelRecords(await store.listRuns().catch(() => []));
273
+ const withRecord = (m: ModelOption): ModelOption => {
274
+ const r = records.get(m.id) ?? records.get(m.model) ?? records.get(m.label);
275
+ const line = recordLine(r);
276
+ return line ? { ...m, record: line } : m;
277
+ };
251
278
 
252
279
  // Anthropic, via whichever Claude Code install or key the server resolves.
253
280
  // Always offered: it is the default, and the shipped configuration.
@@ -325,7 +352,7 @@ async function availableModels(project: Project | null): Promise<{
325
352
  count: out.filter((x) => x.providerLabel === m.providerLabel).length,
326
353
  }));
327
354
 
328
- return { models: out, groups, reachable: true };
355
+ return { models: out.map(withRecord), groups, reachable: true };
329
356
  }
330
357
 
331
358
  /**
@@ -346,6 +373,26 @@ function providerForRole(meta: RunMeta, roleProviderId?: string): ProviderRef {
346
373
  return own;
347
374
  }
348
375
 
376
+
377
+ /**
378
+ * The one provider problem `resolveProvider` cannot see: a Claude Code
379
+ * provider whose install is not signed in. Resolution only names the config
380
+ * dir; whether anything is logged into it is a fact about the machine, read
381
+ * fresh here so a login done a minute ago counts. Said in Foreman's words,
382
+ * because the SDK's own — "Not logged in · Please run /login" — reads like a
383
+ * Foreman slash command and names nothing the person can do from here.
384
+ */
385
+ async function signedInProblem(resolved: ResolvedProvider): Promise<string | null> {
386
+ if (resolved.kind !== 'claude-code') return null;
387
+ const signedIn = resolved.ownLogin
388
+ ? await dirHasCredentials(resolved.configDir)
389
+ : (await detectAuth()).mode !== 'none';
390
+ if (signedIn) return null;
391
+ return 'Claude Code is not signed in on this machine, so nothing can run on it yet. ' +
392
+ 'Sign in — run `claude` in a terminal, then `/login` — and restart Foreman; ' +
393
+ 'or give this project a provider of its own (an Anthropic API key, Codex, or an OpenAI-compatible endpoint) in Settings → Provider.';
394
+ }
395
+
349
396
  /** Whether a project's provider has a key on file. Never the key itself. */
350
397
  async function providerHasKeyOf(p: Project): Promise<boolean> {
351
398
  const ref = providerOf(p);
@@ -368,7 +415,6 @@ function toPath(v: unknown): string | undefined {
368
415
  return typeof v === 'string' && v.trim() ? v.trim() : undefined;
369
416
  }
370
417
 
371
- /** Parses a model choice: a known alias or a full claude-* id; else inherit. */
372
418
  /**
373
419
  * A model id from any provider.
374
420
  *
@@ -419,6 +465,19 @@ async function locateFolders(name: string): Promise<string[]> {
419
465
  }
420
466
 
421
467
  const PORT = Number(process.env.PORT ?? 4177);
468
+ /**
469
+ * Where the dev servers the crew exposed are proxied — a second port, and so a
470
+ * second origin. Those pages are written by an agent, and a browser gives a
471
+ * page the run of every API on its own origin: served from Foreman's port, an
472
+ * exposed preview could start missions, read the folder tree and spend the
473
+ * budget with a line of fetch(). On a port of its own the same-origin policy
474
+ * does that refusing for us. FOREMAN_SERVICES_PORT overrides; PORT + 1 by default.
475
+ */
476
+ const SERVICES_PORT = (() => {
477
+ const raw = process.env.FOREMAN_SERVICES_PORT;
478
+ const n = Number(raw);
479
+ return raw && Number.isInteger(n) && n >= 1 && n <= 65535 ? n : PORT + 1;
480
+ })();
422
481
  /**
423
482
  * Where to listen. Default: loopback, plus the tailnet address when this
424
483
  * machine is on one — never every interface, since there is no login.
@@ -451,12 +510,30 @@ const authPromise = detectAuth();
451
510
  const activeByProject = new Map<string, MissionRun | null>();
452
511
  const sseClients = new Set<http.ServerResponse>();
453
512
 
513
+ /**
514
+ * A comment frame down every open stream, often enough that nothing in the
515
+ * middle calls the connection dead.
516
+ *
517
+ * A quiet fleet produces no events for minutes at a time, and an idle stream
518
+ * is exactly what proxies (`tailscale serve`, anything else in front) and
519
+ * phone radios reclaim — the dashboard then sits there looking live while
520
+ * receiving nothing. `:` starts a comment in the SSE grammar, so this costs
521
+ * the client nothing to parse and never reaches an event handler. Unref'd:
522
+ * it must not be the reason the process stays up. Clients that have gone
523
+ * away are dropped here rather than waiting for a real event to notice.
524
+ */
525
+ setInterval(() => {
526
+ for (const res of sseClients) {
527
+ if (res.destroyed || res.writableEnded) { sseClients.delete(res); continue; }
528
+ try { res.write(': ping\n\n'); } catch { sseClients.delete(res); }
529
+ }
530
+ }, 25_000).unref();
531
+
454
532
  function activeRuns(): MissionRun[] {
455
533
  // Filter out reservation placeholders (see reserveProject).
456
534
  return [...activeByProject.values()].filter((r): r is MissionRun => Boolean(r));
457
535
  }
458
536
 
459
- /** Broadcasts an enveloped frame to live clients and persists the bare event. */
460
537
  /**
461
538
  * What happened in the fleet lately, in one line each, for the front desk.
462
539
  *
@@ -559,10 +636,6 @@ function ledgerKeyFor(meta: RunMeta): string {
559
636
  return `${meta.id}.${meta.resumes ?? 0}`;
560
637
  }
561
638
 
562
- /**
563
- * Chat frames carry `chat: true` and no run id, so a UI following the same
564
- * stream can tell a planning conversation from a mission without guessing.
565
- */
566
639
  /**
567
640
  * A chat event for open tabs only, not the log: the log it would describe
568
641
  * is the one being thrown away. Used when a conversation is cleared, so a
@@ -735,6 +808,55 @@ async function findProject(ref: string): Promise<Project | null> {
735
808
  ?? null;
736
809
  }
737
810
 
811
+ // ---------------------------------------------------------------------------
812
+ // Linking a repository: clone under the projects root, then link the folder
813
+ // ---------------------------------------------------------------------------
814
+
815
+ interface CloneJob {
816
+ id: string; url: string; dest: string; startedAt: number;
817
+ state: 'running' | 'done' | 'error'; progress: string; projectId?: string; error?: string;
818
+ }
819
+ /** Clones in flight or recently finished, for the picker to poll. Memory only; a restart forgets them (the folder stays). */
820
+ const cloneJobs = new Map<string, CloneJob>();
821
+
822
+ interface InstallJob { id: string; what: 'chromium'; startedAt: number; state: 'running' | 'done' | 'error'; progress: string; error?: string }
823
+ /** Browser installs started from the setup page; one at a time, polled like a clone. */
824
+ const installJobs = new Map<string, InstallJob>();
825
+
826
+ /**
827
+ * Where a repository lands and whether it may: under the projects root, by
828
+ * its own name, never over something that is already there. Returns the
829
+ * destination or the reason it cannot be used.
830
+ */
831
+ async function cloneDestination(url: string): Promise<{ ok: true; ref: NonNullable<ReturnType<typeof parseRepoUrl>>; dest: string } | { ok: false; error: string }> {
832
+ const ref = parseRepoUrl(url);
833
+ if (!ref) return { ok: false, error: `"${url}" is not a Git URL Foreman recognises. Try https://github.com/owner/repo, git@host:owner/repo.git, or owner/repo.` };
834
+ const settings = await store.readSettings().catch(() => ({ global: {}, projects: {} }));
835
+ const root = projectsRoot((settings.global as Record<string, unknown>).projectsRoot);
836
+ const dest = path.join(root, ref.name);
837
+ if (await stat(dest).catch(() => null)) {
838
+ const linked = (await store.listProjects()).find((p) => p.folder === dest);
839
+ return { ok: false, error: linked ? `${ref.name} is already here and linked as ${linked.name}.` : `${dest} already exists. Link that folder instead, or move it aside.` };
840
+ }
841
+ await mkdir(root, { recursive: true });
842
+ return { ok: true, ref, dest };
843
+ }
844
+
845
+ /** Clone and link, start to finish. Used by the front desk and the phone, which wait; the picker uses the job table instead. */
846
+ async function cloneAndLink(url: string, branch?: string, onProgress?: (line: string) => void): Promise<{ project?: Project; error?: string }> {
847
+ const where = await cloneDestination(url);
848
+ if (!where.ok) return { error: where.error };
849
+ const failed = await cloneRepo({ ref: where.ref, dest: where.dest, branch, onProgress });
850
+ if (failed) {
851
+ // A half-clone is worse than none: git leaves the folder on failure.
852
+ await rm(where.dest, { recursive: true, force: true }).catch(() => {});
853
+ return { error: failed };
854
+ }
855
+ const project = await store.addProject(where.dest);
856
+ projectsCache.set(project.id, { name: project.name });
857
+ return { project };
858
+ }
859
+
738
860
  // ---------------------------------------------------------------------------
739
861
  // The fleet planner — the front desk
740
862
  // ---------------------------------------------------------------------------
@@ -850,6 +972,8 @@ const fleetHost: FleetHost = {
850
972
  if (words.error) lines.push(`it stopped with: ${clipText(words.error, 300)}`);
851
973
  }
852
974
  }
975
+ const rec = projectRecord((await store.listRuns().catch(() => [] as RunMeta[])).filter((r) => r.folder === project.folder));
976
+ if (rec.done >= 2 && rec.medianCostUsd !== undefined) lines.push(`track record: ${rec.done} of ${rec.runs} missions finished; a finished one here costs about $${rec.medianCostUsd.toFixed(2)}${rec.medianMinutes !== undefined ? ` and takes ~${rec.medianMinutes} min` : ''}${rec.capHits ? `; ${rec.capHits} hit the cap` : ''}`);
853
977
  const meta = await store.readChatMeta(project.id).catch(() => null);
854
978
  if (meta?.proposal) lines.push(`a proposal is waiting for Start or Discard: "${clipText(firstLine(meta.proposal.mission), 100)}" cap $${meta.proposal.budgetUsd}`);
855
979
  if (chatTurns.has(project.id)) lines.push('its planner is replying right now');
@@ -888,6 +1012,11 @@ const fleetHost: FleetHost = {
888
1012
  },
889
1013
 
890
1014
  async linkProject(folderIn) {
1015
+ if (looksLikeRepoUrl(folderIn)) {
1016
+ const r = await cloneAndLink(folderIn.trim());
1017
+ if (r.error) return r.error;
1018
+ return `Cloned ${r.project!.name} into ${r.project!.folder} and linked it. Its planner can read it now (open_planning).`;
1019
+ }
891
1020
  const folder = expandHome(folderIn.trim());
892
1021
  if (!path.isAbsolute(folder)) return `A folder to link must be an absolute path (or ~/…), not "${folderIn}".`;
893
1022
  const st = await stat(folder).catch(() => null);
@@ -949,7 +1078,7 @@ async function driveFleetTurn(text: string, via: 'telegram' | 'http'): Promise<{
949
1078
  try {
950
1079
  const resolved = await resolveProvider(providerOf({}), store.root);
951
1080
  emit('chat_turn', { state: 'thinking', model, provider: resolved.label, costBasis: resolved.costBasis });
952
- const problem = providerProblem(resolved);
1081
+ const problem = providerProblem(resolved) ?? await signedInProblem(resolved);
953
1082
  if (problem) {
954
1083
  emit('chat_error', { error: `provider unavailable — ${problem}` });
955
1084
  return { text: '', costUsd: 0, error: `provider unavailable — ${problem}` };
@@ -963,7 +1092,7 @@ async function driveFleetTurn(text: string, via: 'telegram' | 'http'): Promise<{
963
1092
  const result = await runFleetTurn({
964
1093
  sessionId: meta.sessionId, text, model, cwd, host: fleetHost, via,
965
1094
  news: fleetNews(since), sinceMs: Date.now() - since,
966
- models: models.map((m) => ({ id: m.id, label: m.label, providerId: m.providerId, providerLabel: m.providerLabel, costBasis: m.costBasis, note: m.note })),
1095
+ models: models.map((m) => ({ id: m.id, label: m.label, providerId: m.providerId, providerLabel: m.providerLabel, costBasis: m.costBasis, note: m.note, record: m.record })),
967
1096
  agentEnv: await agentEnvFor(resolved, `chat:${FLEET_CHAT_ID}`),
968
1097
  emit, abort,
969
1098
  });
@@ -1067,6 +1196,13 @@ async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
1067
1196
  return say(`<b>Running · ${live.length}</b>\n${lines.join('\n')}${planning.length ? `\n\n<b>Planning</b>\n${planning.join('\n')}` : ''}`);
1068
1197
  }
1069
1198
  case 'new': {
1199
+ if (looksLikeRepoUrl(cmd.name)) {
1200
+ say(`Cloning <code>${escTg(cmd.name.trim())}</code>…`);
1201
+ const r = await cloneAndLink(cmd.name.trim());
1202
+ if (r.error) return say(`Could not clone: ${escTg(r.error)}`);
1203
+ lastPhonePlanning = r.project!.id; lastPhonePlanningAt = Date.now();
1204
+ return say(`Cloned <b>${escTg(r.project!.name)}</b> into <code>${escTg(r.project!.folder)}</code> and linked it.\nTell me what it should do next — just type it.`);
1205
+ }
1070
1206
  const settings = await store.readSettings().catch(() => ({ global: {}, projects: {} }));
1071
1207
  const root = projectsRoot((settings.global as Record<string, unknown>).projectsRoot);
1072
1208
  const name = slug(cmd.name);
@@ -1137,11 +1273,6 @@ async function chatMetaOf(projectId: string): Promise<ChatMeta> {
1137
1273
  ?? { projectId, costUsd: 0, createdAt: now, updatedAt: now };
1138
1274
  }
1139
1275
 
1140
- /**
1141
- * Runs one planning turn: the human's message goes into the log first (so a
1142
- * reload mid-turn still shows what was asked), then the planner's reply
1143
- * streams out through the same envelope machinery as a mission.
1144
- */
1145
1276
  /**
1146
1277
  * `shown` is what the transcript records as the human's message when it
1147
1278
  * differs from what the planner is sent (a fork's seed). `via: 'telegram'`
@@ -1181,7 +1312,7 @@ async function driveChatTurn(project: Project, text: string, shown: string = tex
1181
1312
  provider: resolved.label,
1182
1313
  costBasis: resolved.costBasis,
1183
1314
  });
1184
- const problem = providerProblem(resolved);
1315
+ const problem = providerProblem(resolved) ?? await signedInProblem(resolved);
1185
1316
  if (problem) {
1186
1317
  emit('chat_error', { error: `provider unavailable — ${problem}` });
1187
1318
  return;
@@ -1196,8 +1327,9 @@ async function driveChatTurn(project: Project, text: string, shown: string = tex
1196
1327
  projectId: project.id,
1197
1328
  models: models.map((m) => ({
1198
1329
  id: m.id, label: m.label, providerId: m.providerId,
1199
- providerLabel: m.providerLabel, costBasis: m.costBasis, note: m.note,
1330
+ providerLabel: m.providerLabel, costBasis: m.costBasis, note: m.note, record: m.record,
1200
1331
  })),
1332
+ anchor: await budgetAnchorFor(project).catch(() => ''),
1201
1333
  sessionId: meta.sessionId,
1202
1334
  folder: project.folder,
1203
1335
  text,
@@ -1243,18 +1375,25 @@ async function driveChatTurn(project: Project, text: string, shown: string = tex
1243
1375
  }
1244
1376
 
1245
1377
  /**
1246
- * A mission has started, so the proposal that led to it is spent. Recording
1247
- * the handoff in the conversation matters as much as clearing it: the chat is
1248
- * the story of how this mission came to exist, and it should not simply stop
1249
- * at the moment the work began.
1378
+ * A mission has started, so the conversation that led to it is finished.
1379
+ *
1380
+ * The handoff is recorded first — the chat is the story of how this mission
1381
+ * came to exist, and it should not simply stop at the moment the work began —
1382
+ * and then the whole conversation is archived, not left as the project's
1383
+ * front page. A project visited after a mission shows its runs and a blank
1384
+ * line, not a spent planning session scrolled to the bottom; the run is the
1385
+ * continuation, and "Plan the next step" forks a new conversation from it.
1386
+ * Open tabs are told, so they reset without a reload.
1250
1387
  */
1251
1388
  async function consumeProposal(projectId: string, runId: string, mission: string): Promise<void> {
1252
1389
  const meta = await store.readChatMeta(projectId).catch(() => null);
1253
1390
  if (!meta) return;
1254
- makeChatEmitter(projectId)('mission_started', { runId, mission });
1255
- if (!meta.proposal) return;
1256
- const { proposal: _spent, ...rest } = meta;
1257
- await store.writeChatMeta({ ...rest, updatedAt: Date.now() }).catch(() => {});
1391
+ const handoff = { runId, mission };
1392
+ await store.appendChat(projectId, { ts: Date.now(), event: 'mission_started', data: handoff }).catch(() => {});
1393
+ noteFleetEvent(projectId, 'mission_started', handoff);
1394
+ notifyHub.handle({ event: 'mission_started', runId: null, projectId, chat: true, data: handoff, ts: Date.now() });
1395
+ await store.archiveChat(projectId, runId).catch(() => {});
1396
+ broadcastChat(projectId, 'chat_cleared', {});
1258
1397
  }
1259
1398
 
1260
1399
  /**
@@ -1268,6 +1407,28 @@ function reserveProject(projectId: string): boolean {
1268
1407
  return true;
1269
1408
  }
1270
1409
 
1410
+ /**
1411
+ * Where an exposed service is reachable from, without the trailing slash — the
1412
+ * same address the human already uses for Foreman, but on SERVICES_PORT.
1413
+ *
1414
+ * The starting point is the URL the dashboard hands out (Settings, else the
1415
+ * tailnet, else localhost), because that is the one that actually reaches this
1416
+ * machine from wherever the human reads their notifications. Only the port
1417
+ * changes. An https URL cannot simply be re-pointed: it is `tailscale serve`
1418
+ * terminating TLS in front of the main port, and serve fronts that port alone,
1419
+ * so the honest answer is plain http straight at the tailnet address.
1420
+ */
1421
+ async function servicesBase(): Promise<string> {
1422
+ const publicUrl = (await notifySettings().catch(() => null))?.publicUrl
1423
+ ?? (tailnet ? tailnetUrl(tailnet, PORT) : `http://localhost:${PORT}`);
1424
+ const direct = () => `http://${tailnet ? (tailnet.dnsName ?? tailnet.ip) : 'localhost'}:${SERVICES_PORT}`;
1425
+ let u: URL;
1426
+ try { u = new URL(publicUrl); } catch { return direct(); }
1427
+ if (u.protocol !== 'http:') return direct();
1428
+ u.port = String(SERVICES_PORT);
1429
+ return u.origin;
1430
+ }
1431
+
1271
1432
  /** Runs a mission to completion. The project must already be reserved. */
1272
1433
  async function driveRun(
1273
1434
  projectId: string, meta: RunMeta, resume?: { sessionId?: string },
@@ -1278,7 +1439,7 @@ async function driveRun(
1278
1439
  // A run that cannot resolve a credential must not start: dispatching anyway
1279
1440
  // would fall back to whatever the environment happens to hold.
1280
1441
  const resolved = await resolveProvider(providerOf(meta), store.root);
1281
- const problem = providerProblem(resolved);
1442
+ const problem = providerProblem(resolved) ?? await signedInProblem(resolved);
1282
1443
  if (problem) {
1283
1444
  meta.status = 'error';
1284
1445
  meta.endedAt = Date.now();
@@ -1288,6 +1449,29 @@ async function driveRun(
1288
1449
  activeByProject.delete(projectId);
1289
1450
  return;
1290
1451
  }
1452
+ // Each role's provider, checked against its model before anything is
1453
+ // resolved. Model and provider are picked together but stored apart, and
1454
+ // a stale pair — `fable` pinned to Codex by an older settings file — fails
1455
+ // on the first turn with an upstream 400 nobody chose. The model list is
1456
+ // the authority; a corrected role is written back and said in the
1457
+ // transcript, so the run reads the way it actually ran.
1458
+ {
1459
+ const project = await store.getProject(projectId).catch(() => null);
1460
+ const { models } = await availableModels(project).catch(() => ({ models: [] as ModelOption[] }));
1461
+ const known = models.map((m) => ({ id: m.id, providerId: m.providerId }));
1462
+ const director = reconcileRole('director', meta.directorModel, meta.directorProviderId, known);
1463
+ const worker = reconcileRole('workers', meta.workerModel, meta.workerProviderId, known);
1464
+ const notes = [director.note, worker.note].filter((n): n is string => Boolean(n));
1465
+ if (notes.length) {
1466
+ meta.directorProviderId = director.providerId;
1467
+ meta.workerProviderId = worker.providerId;
1468
+ await store.writeMeta(meta).catch(() => {});
1469
+ emit('models_changed', {
1470
+ text: `Provider corrected before dispatch — ${notes.join(' ')}`,
1471
+ directorModel: meta.directorModel, workerModel: meta.workerModel,
1472
+ });
1473
+ }
1474
+ }
1291
1475
  let agentEnv;
1292
1476
  let roleBasis = resolved.costBasis;
1293
1477
  let prices: { director?: ModelPrice; worker?: ModelPrice } = {};
@@ -1372,11 +1556,14 @@ async function driveRun(
1372
1556
  // A dev server behind Foreman's address. Declared ports only, and only
1373
1557
  // ones something is listening on — an agent cannot reserve a path for a
1374
1558
  // server it has not started.
1559
+ // Detected per dispatch, so a browser installed from the setup page
1560
+ // serves the very next mission without a restart.
1561
+ browserChannel: (await detectBrowser())?.channel,
1375
1562
  exposeService: async (runId, port, label) => {
1563
+ if (servicesDown) return { ok: false, reason: servicesDown };
1376
1564
  if (!(await portOpen(port))) return { ok: false, reason: `nothing is listening on 127.0.0.1:${port} — start the server first` };
1377
1565
  const svc = services.register(runId, port, label);
1378
- const base = (await notifySettings().catch(() => null))?.publicUrl ?? (tailnet ? tailnetUrl(tailnet, PORT) : `http://localhost:${PORT}`);
1379
- return { ok: true, url: `${base.replace(/\/+$/, '')}${svc.path}`, path: svc.path };
1566
+ return { ok: true, url: `${await servicesBase()}${svc.path}`, path: svc.path };
1380
1567
  },
1381
1568
  });
1382
1569
  activeByProject.set(projectId, run);
@@ -1408,6 +1595,23 @@ async function driveRun(
1408
1595
  // However the run ended, it no longer needs its gateways.
1409
1596
  releaseGateways(meta.id);
1410
1597
  if (activeByProject.get(projectId) === run) activeByProject.delete(projectId);
1598
+ // A finished mission on its own branch closes with a commit of whatever
1599
+ // the crew left uncommitted. Done only: an interrupted run resumes on
1600
+ // the same branch and its tree, and an error is not a result to record.
1601
+ if (meta.git && meta.status === 'done') {
1602
+ const label = meta.title || meta.mission.split('\n').find((l) => l.trim())?.trim().slice(0, 72) || meta.id;
1603
+ const closed = await closeMissionBranch(meta.folder, meta.git, `foreman: ${label}`);
1604
+ meta.git = { branch: closed.branch, base: closed.base, baseHead: closed.baseHead, commits: closed.commits, commit: closed.commit ?? meta.git.commit };
1605
+ await store.writeMeta(meta).catch(() => {});
1606
+ gitInfoCache.delete(meta.folder);
1607
+ const ahead = closed.commits ?? 0;
1608
+ emit('git_committed', {
1609
+ branch: closed.branch, base: closed.base, commits: ahead, commit: closed.commit, committed: closed.committed, error: closed.error,
1610
+ text: closed.error
1611
+ ? `Could not commit the mission's work on ${closed.branch}: ${closed.error}`
1612
+ : `${closed.committed ? `Committed the mission's work as ${closed.commit}` : 'Nothing left to commit'} — ${closed.branch} is ${ahead} commit${ahead === 1 ? '' : 's'} ahead of ${closed.base}. Merge or open a pull request when you are ready; Foreman does neither.`,
1613
+ });
1614
+ }
1411
1615
  }
1412
1616
  }
1413
1617
 
@@ -1417,6 +1621,8 @@ async function effectiveSettings(projectId: string): Promise<{
1417
1621
  directorModel: ModelChoice; workerModel: ModelChoice; plannerModel: ModelChoice;
1418
1622
  /** Provider serving each role, when Settings pinned one with the model. */
1419
1623
  directorProviderId?: string; workerProviderId?: string;
1624
+ /** In a repository, each mission runs on a branch of its own (default on). */
1625
+ gitBranchPerMission: boolean;
1420
1626
  }> {
1421
1627
  const s = await store.readSettings()
1422
1628
  .catch(() => ({ global: {}, projects: {} as Record<string, object> }));
@@ -1437,9 +1643,26 @@ async function effectiveSettings(projectId: string): Promise<{
1437
1643
  plannerModel: modelChoice(p.plannerModel ?? g.plannerModel),
1438
1644
  directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
1439
1645
  workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
1646
+ gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
1440
1647
  };
1441
1648
  }
1442
1649
 
1650
+ /** What missions have cost in this project and across the fleet, as the planner's anchor. */
1651
+ async function budgetAnchorFor(project: Project): Promise<string> {
1652
+ const runs = await store.listRuns();
1653
+ return budgetAnchor(projectRecord(runs.filter((r) => r.folder === project.folder)), projectRecord(runs), project.name);
1654
+ }
1655
+
1656
+ /** Git facts per folder, for the fleet poll — asked at most every few seconds per project. */
1657
+ const gitInfoCache = new Map<string, { at: number; value: GitInfo }>();
1658
+ async function gitInfoCached(folder: string): Promise<GitInfo> {
1659
+ const hit = gitInfoCache.get(folder);
1660
+ if (hit && Date.now() - hit.at < 5_000) return hit.value;
1661
+ const value = await gitInfo(folder);
1662
+ gitInfoCache.set(folder, { at: Date.now(), value });
1663
+ return value;
1664
+ }
1665
+
1443
1666
  async function startRun(
1444
1667
  projectId: string, folder: string, mission: string, budgetUsd: number,
1445
1668
  directorModel: ModelChoice, workerModel: ModelChoice, browserTools: boolean,
@@ -1476,6 +1699,21 @@ async function startRun(
1476
1699
  return;
1477
1700
  }
1478
1701
  await consumeProposal(projectId, meta.id, mission).catch(() => {});
1702
+ // In a repository, the mission gets a branch of its own before the crew
1703
+ // touches anything — so the deck's baseline, taken at the director's first
1704
+ // turn, is the branch point, and the diff is exactly the mission.
1705
+ if (settings.gitBranchPerMission) {
1706
+ const g = await startMissionBranch(folder, mission, meta.id);
1707
+ const emit = makeEmitter(meta.id, projectId);
1708
+ if ('error' in g) {
1709
+ if (g.error !== 'not a git repository') emit('git_note', { text: `Could not give this mission its own branch (${g.error}); running on the current branch.` });
1710
+ } else {
1711
+ meta.git = g;
1712
+ await store.writeMeta(meta).catch(() => {});
1713
+ gitInfoCache.delete(folder);
1714
+ emit('git_branch', { branch: g.branch, base: g.base, text: `On branch ${g.branch}, made from ${g.base}. Foreman commits the mission's work here when it ends; merging and pushing stay yours.` });
1715
+ }
1716
+ }
1479
1717
  await driveRun(projectId, meta);
1480
1718
  }
1481
1719
 
@@ -1513,6 +1751,11 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
1513
1751
  meta.status = 'running';
1514
1752
  meta.endedAt = undefined;
1515
1753
  meta.resumes = (meta.resumes ?? 0) + 1;
1754
+ if (meta.git) {
1755
+ const back = await ensureMissionBranch(meta.folder, meta.git.branch);
1756
+ if (back) makeEmitter(meta.id, projectId)('git_note', { text: `Could not return to ${meta.git.branch} (${back}); the resumed mission runs on whatever is checked out.` });
1757
+ gitInfoCache.delete(meta.folder);
1758
+ }
1516
1759
  await store.writeMeta(meta).catch((err) => {
1517
1760
  console.error(`failed to persist resume of ${meta.id}:`, err);
1518
1761
  });
@@ -1557,13 +1800,52 @@ async function serveStatic(pathname: string, res: http.ServerResponse): Promise<
1557
1800
  // Request helpers
1558
1801
  // ---------------------------------------------------------------------------
1559
1802
 
1803
+ /**
1804
+ * The request body as an object, refusing to buffer more than the route's cap.
1805
+ *
1806
+ * Both failures throw a `BodyError`, which the handler's outer catch turns
1807
+ * into the client's own status instead of a 500 that reads like a Foreman bug:
1808
+ * 413 when the body outgrows the cap (see `src/http-body.ts` for why there is
1809
+ * one), 400 when what arrived is not JSON. Content-Length is consulted first
1810
+ * so an oversized upload is refused before a single byte is read.
1811
+ */
1560
1812
  async function readBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
1813
+ const pathname = new URL(req.url ?? '/', `http://localhost:${PORT}`).pathname;
1814
+ const limit = bodyLimitFor(req.method, pathname);
1815
+ const declared = Number(req.headers['content-length']);
1816
+ if (Number.isFinite(declared) && declared > limit) throw new BodyError(413, 'request body too large');
1561
1817
  const chunks: Buffer[] = [];
1562
- for await (const c of req) chunks.push(c as Buffer);
1818
+ let seen = 0;
1819
+ for await (const c of req) {
1820
+ const buf = c as Buffer;
1821
+ seen += buf.length;
1822
+ if (seen > limit) throw new BodyError(413, 'request body too large');
1823
+ chunks.push(buf);
1824
+ }
1563
1825
  if (!chunks.length) return {};
1564
- const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString());
1565
- return typeof parsed === 'object' && parsed !== null
1566
- ? (parsed as Record<string, unknown>) : {};
1826
+ return parseBody(Buffer.concat(chunks));
1827
+ }
1828
+
1829
+ /**
1830
+ * The folders the folder picker may look inside.
1831
+ *
1832
+ * `/browse` and `/mkdir` exist to let the human point at a project, and they
1833
+ * took any absolute path — which made them a directory listing and a
1834
+ * `mkdir -p` for the whole filesystem, reachable from a page that got past
1835
+ * nothing but a URL. The answer is the same one the permission cards use:
1836
+ * name the places a project could plausibly live — the home directory, the
1837
+ * configured projects root (it may be on another volume), and every folder
1838
+ * already linked as a project — and refuse the rest. Read afresh per request
1839
+ * because linking a project is what widens the set.
1840
+ */
1841
+ async function browseRoots(): Promise<string[]> {
1842
+ const settings = await store.readSettings().catch(() => ({ global: {} as Record<string, unknown> }));
1843
+ const roots = [
1844
+ os.homedir(),
1845
+ projectsRoot((settings.global as Record<string, unknown>).projectsRoot),
1846
+ ];
1847
+ for (const p of await store.listProjects().catch(() => [])) roots.push(p.folder);
1848
+ return roots;
1567
1849
  }
1568
1850
 
1569
1851
  function json(res: http.ServerResponse, code: number, body: unknown): void {
@@ -1576,40 +1858,31 @@ function json(res: http.ServerResponse, code: number, body: unknown): void {
1576
1858
  // ---------------------------------------------------------------------------
1577
1859
 
1578
1860
  const server = http.createServer(async (req, res) => {
1861
+ // Before any route, including the service proxy: is this request one we
1862
+ // should be answering at all? Foreman has no login, so a browser that can
1863
+ // reach the port can otherwise act as the operator — a page on any domain
1864
+ // can point that name at 127.0.0.1 (DNS rebinding) or simply POST at
1865
+ // localhost from whatever tab the operator has open. See src/guard.ts.
1866
+ const verdict = requestAllowed(req, { port: PORT, tailnet, bindAll: BIND === 'all' });
1867
+ if (!verdict.ok) { json(res, verdict.status, { error: verdict.error }); return; }
1579
1868
  const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
1580
- // Services the crew exposed: /svc/<run>/<port>/… goes to 127.0.0.1:<port>,
1581
- // but only for a pair a run declared. A page served this way asks for its
1582
- // absolute-path assets (`/app.js`) against Foreman's root; those arrive as
1583
- // sub-resource requests carrying the service page as Referer, and are
1584
- // routed to the same service. Documents never are — a typed URL is Foreman's.
1585
- {
1586
- const svc = parseServicePath(url.pathname);
1587
- if (svc) {
1588
- if (!services.has(svc.runId, svc.port)) { json(res, 404, { error: 'no such service' }); return; }
1589
- proxyToService(req, res, svc.port, svc.rest, url.search, servicePath(svc.runId, svc.port));
1590
- return;
1591
- }
1592
- const ref = req.headers.referer;
1593
- const dest = String(req.headers['sec-fetch-dest'] ?? '');
1594
- if (ref && dest && dest !== 'document' && dest !== 'empty' && !url.pathname.startsWith(SVC_PREFIX)) {
1595
- try {
1596
- const via = parseServicePath(new URL(ref).pathname);
1597
- if (via && services.has(via.runId, via.port)) {
1598
- proxyToService(req, res, via.port, url.pathname, url.search, servicePath(via.runId, via.port));
1599
- return;
1600
- }
1601
- } catch { /* not a URL we can read — fall through to Foreman's own routes */ }
1602
- }
1603
- }
1869
+ // Note: /svc/… is not handled here. Exposed dev servers live on
1870
+ // SERVICES_PORT, on their own origin — see servicesServer below.
1604
1871
  const runEventsMatch = url.pathname.match(/^\/runs\/([^/]+)\/events$/);
1605
1872
  // The deck: what a run changed and what it produced. Read-only by design —
1606
1873
  // a stated non-goal — and handled before the chain because it owns two paths
1607
1874
  // under /runs/{id}/ that nothing else claims.
1608
- if (await handleDeckRoute(req, res, url, async (id) => {
1875
+ if (await handleDeckRoute(req, res, url, async (scope, id) => {
1876
+ if (scope === 'projects') {
1877
+ const project = await store.getProject(id).catch(() => null);
1878
+ return project ? { folder: project.folder } : null;
1879
+ }
1609
1880
  const m = await store.readMeta(id).catch(() => null);
1610
1881
  return m ? { folder: m.folder } : null;
1611
1882
  })) return;
1612
1883
  const runResumeMatch = url.pathname.match(/^\/runs\/([^/]+)\/resume$/);
1884
+ const prMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr$/);
1885
+ const prStateMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr\/state$/);
1613
1886
  const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
1614
1887
  const providerKeyMatch = url.pathname.match(/^\/providers\/([A-Za-z0-9_-]{1,64})\/key$/);
1615
1888
 
@@ -1631,6 +1904,57 @@ const server = http.createServer(async (req, res) => {
1631
1904
  sseClients.add(res);
1632
1905
  req.on('close', () => sseClients.delete(res));
1633
1906
 
1907
+ } else if (req.method === 'GET' && url.pathname === '/doctor') {
1908
+ // What `foreman doctor` prints, as data, for the dashboard's setup card.
1909
+ // Computed fresh each time — the point is to reflect a login or an
1910
+ // install the person just did. The port and dashboard checks are left
1911
+ // out: whoever is reading this in the dashboard already knows both.
1912
+ const [checks, auth, ollama, codexAuth, notify] = await Promise.all([
1913
+ preflight({ port: PORT, servicesPort: SERVICES_PORT, foremanHome: store.root, distDir: DIST_DIR, tailnet }),
1914
+ detectAuth(),
1915
+ discoverOllama(1200),
1916
+ readCodexAuth(codexHome()).catch(() => null),
1917
+ notifySettings().catch(() => null),
1918
+ ]);
1919
+ const codexCount = codexAuth ? (await codexModels(codexHome()).catch(() => [])).length : 0;
1920
+ json(res, 200, {
1921
+ checks: checks.filter((c) => !c.name.startsWith('Port') && c.name !== 'Dashboard'),
1922
+ // The same facts, shaped for the first-run pages: one object per step.
1923
+ auth: { mode: auth.mode, source: auth.source, account: auth.account ?? null },
1924
+ ollama: ollama ? { host: ollamaHost(), models: ollama.length, local: ollama.filter((m) => !m.remote).length } : null,
1925
+ codex: codexAuth ? { models: codexCount } : null,
1926
+ tailnet: tailnet ? { dnsName: tailnet.dnsName ?? null, ip: tailnet.ip, url: tailnetUrl(tailnet, PORT), https: Boolean(tailnet.httpsPort) } : null,
1927
+ phone: notify?.telegramChatId ? { label: notify.telegramChatLabel ?? 'linked', bot: notify.telegramBot ?? null } : notify?.telegramBot ? { label: null, bot: notify.telegramBot } : null,
1928
+ browser: checks.find((c) => c.name === 'Browser') ?? null,
1929
+ version: currentVersion(),
1930
+ });
1931
+
1932
+ } else if (req.method === 'POST' && url.pathname === '/setup/browser') {
1933
+ // Install Playwright's Chromium, as the user, into Playwright's cache.
1934
+ // The one install action the setup page has: no root, no system
1935
+ // packages, and the crew's browser tool can drive the result.
1936
+ const running = [...installJobs.values()].find((j) => j.state === 'running');
1937
+ if (running) return json(res, 202, { id: running.id });
1938
+ const job: InstallJob = { id: crypto.randomBytes(6).toString('hex'), what: 'chromium', startedAt: Date.now(), state: 'running', progress: 'Starting…' };
1939
+ installJobs.set(job.id, job);
1940
+ void installChromium((l) => { job.progress = l; }).then((failed) => {
1941
+ if (failed) { job.state = 'error'; job.error = failed; } else { job.state = 'done'; job.progress = 'Installed'; }
1942
+ setTimeout(() => installJobs.delete(job.id), 10 * 60_000).unref();
1943
+ });
1944
+ json(res, 202, { id: job.id });
1945
+
1946
+ } else if (req.method === 'GET' && url.pathname.startsWith('/setup/browser/')) {
1947
+ const job = installJobs.get(url.pathname.split('/').pop()!);
1948
+ if (!job) return json(res, 404, { error: 'no such install' });
1949
+ json(res, 200, job);
1950
+
1951
+ } else if (req.method === 'POST' && url.pathname === '/setup/done') {
1952
+ // The first-run pages were seen through (or skipped). Remembered in
1953
+ // settings so they do not come back; `#/setup` reopens them on purpose.
1954
+ const current = await store.readSettings();
1955
+ await store.writeSettings({ ...current, global: { ...current.global, setupDoneAt: Date.now() } });
1956
+ json(res, 200, { ok: true });
1957
+
1634
1958
  } else if (req.method === 'GET' && url.pathname === '/settings') {
1635
1959
  json(res, 200, await store.readSettings());
1636
1960
 
@@ -1664,9 +1988,14 @@ const server = http.createServer(async (req, res) => {
1664
1988
  json(res, 200, await availableModels(project));
1665
1989
 
1666
1990
  } else if (req.method === 'GET' && url.pathname === '/projects') {
1667
- const [projects, allRuns, auth] = await Promise.all([
1991
+ const [projects, allRuns, auth, settingsFile] = await Promise.all([
1668
1992
  store.listProjects(), store.listRuns(), authPromise,
1993
+ store.readSettings().catch(() => ({ global: {} as Record<string, unknown>, projects: {} })),
1669
1994
  ]);
1995
+ // A first run: nothing linked, nothing ever run, the setup pages not
1996
+ // yet seen. An install upgraded from before the pages existed has
1997
+ // projects, so it never sees them uninvited.
1998
+ const firstRun = !projects.length && !allRuns.length && !(settingsFile.global as Record<string, unknown>).setupDoneAt;
1670
1999
  const cards = await Promise.all(projects.map(async (p) => {
1671
2000
  const run = activeByProject.get(p.id);
1672
2001
  const plannerAsk = pendingChatQuestion(p.id);
@@ -1680,6 +2009,9 @@ const server = http.createServer(async (req, res) => {
1680
2009
  // Whether a key is on file, never the key. Settings renders
1681
2010
  // "stored / not stored" from this and nothing more.
1682
2011
  providerHasKey: await providerHasKeyOf(p),
2012
+ // Which branch the folder is on, and whether it is dirty — the
2013
+ // header's pill, and what "a branch per mission" starts from.
2014
+ git: await gitInfoCached(p.folder),
1683
2015
  activeRun: run ? { ...run.meta } : null,
1684
2016
  lastRun: lastRun && {
1685
2017
  id: lastRun.id,
@@ -1719,6 +2051,7 @@ const server = http.createServer(async (req, res) => {
1719
2051
  json(res, 200, {
1720
2052
  // Billing mode travels with every fleet poll so the UI can state it
1721
2053
  // plainly wherever money is about to be spent.
2054
+ firstRun,
1722
2055
  authMode: auth.mode,
1723
2056
  authSource: auth.source,
1724
2057
  authAccount: auth.account ?? null,
@@ -2193,6 +2526,62 @@ const server = http.createServer(async (req, res) => {
2193
2526
  if (!ok) return json(res, 404, { error: 'no pending question with that id' });
2194
2527
  json(res, 200, { ok: true });
2195
2528
 
2529
+ } else if (req.method === 'GET' && url.pathname === '/projects/clone/where') {
2530
+ // Where a URL would land, before anyone clicks: the dialog shows the
2531
+ // path as you type, and the same check refuses what the clone would.
2532
+ const repo = (url.searchParams.get('url') ?? '').trim();
2533
+ if (!repo) return json(res, 200, { dest: null });
2534
+ const where = await cloneDestination(repo);
2535
+ json(res, 200, where.ok ? { dest: where.dest, name: where.ref.name, host: where.ref.host } : { dest: null, error: where.error });
2536
+
2537
+ } else if (req.method === 'POST' && url.pathname === '/projects/clone') {
2538
+ // Start a clone under the projects root; the picker polls the job.
2539
+ const { url: repo, branch } = await readBody(req);
2540
+ if (typeof repo !== 'string' || !repo.trim()) return json(res, 400, { error: 'url is required' });
2541
+ const where = await cloneDestination(repo.trim());
2542
+ if (!where.ok) return json(res, 400, { error: where.error });
2543
+ const job: CloneJob = { id: crypto.randomBytes(6).toString('hex'), url: where.ref.url, dest: where.dest, startedAt: Date.now(), state: 'running', progress: 'Starting git…' };
2544
+ cloneJobs.set(job.id, job);
2545
+ void (async () => {
2546
+ const failed = await cloneRepo({ ref: where.ref, dest: where.dest, branch: typeof branch === 'string' && branch.trim() ? branch.trim() : undefined, onProgress: (l) => { job.progress = l; } });
2547
+ if (failed) {
2548
+ await rm(where.dest, { recursive: true, force: true }).catch(() => {});
2549
+ job.state = 'error'; job.error = failed;
2550
+ } else {
2551
+ const project = await store.addProject(where.dest);
2552
+ projectsCache.set(project.id, { name: project.name });
2553
+ job.state = 'done'; job.projectId = project.id; job.progress = 'Done';
2554
+ }
2555
+ // Finished jobs linger long enough to be read, then go.
2556
+ setTimeout(() => cloneJobs.delete(job.id), 10 * 60_000).unref();
2557
+ })();
2558
+ json(res, 202, { id: job.id, dest: job.dest });
2559
+
2560
+ } else if (req.method === 'GET' && /^\/projects\/clone\/[a-f0-9]{12}$/.test(url.pathname)) {
2561
+ const job = cloneJobs.get(url.pathname.split('/').pop()!);
2562
+ if (!job) return json(res, 404, { error: 'no such clone (Foreman may have restarted; check the projects root)' });
2563
+ json(res, 200, { state: job.state, progress: job.progress, projectId: job.projectId, error: job.error, dest: job.dest });
2564
+
2565
+ } else if (req.method === 'GET' && url.pathname === '/search') {
2566
+ // Every run across the fleet whose title, brief, project or folder
2567
+ // says the words. Run records are small and already on disk; no index.
2568
+ const q = (url.searchParams.get('q') ?? '').trim().toLowerCase();
2569
+ if (q.length < 2) return json(res, 200, { runs: [] });
2570
+ const [runs, projects] = await Promise.all([store.listRuns(), store.listProjects()]);
2571
+ const byFolder = new Map(projects.map((p) => [p.folder, p]));
2572
+ const hits = runs.filter((r) => {
2573
+ const project = byFolder.get(r.folder);
2574
+ return [r.title, r.mission, r.folder, project?.name].some((f) => f?.toLowerCase().includes(q));
2575
+ }).sort((a, b) => b.createdAt - a.createdAt).slice(0, 30).map((r) => {
2576
+ const project = byFolder.get(r.folder);
2577
+ return {
2578
+ id: r.id, projectId: r.projectId ?? project?.id ?? null, projectName: project?.name ?? path.basename(r.folder),
2579
+ folder: r.folder, title: r.title, mission: firstLine(r.mission), status: r.status,
2580
+ createdAt: r.createdAt, endedAt: r.endedAt, costUsd: r.costUsd, costBasis: costBasisOf(r),
2581
+ };
2582
+ });
2583
+ json(res, 200, { runs: hits });
2584
+
2196
2585
  } else if (req.method === 'POST' && url.pathname === '/fleet/chat') {
2197
2586
  // One turn at the front desk, answered in the response. The same
2198
2587
  // session the phone uses, so a conversation can move between them.
@@ -2214,6 +2603,72 @@ const server = http.createServer(async (req, res) => {
2214
2603
  broadcastChat(FLEET_CHAT_ID, 'chat_cleared', {});
2215
2604
  json(res, 200, { ok: true });
2216
2605
 
2606
+ } else if (prStateMatch && req.method === 'GET') {
2607
+ // What became of the run's pull request. A final answer (merged,
2608
+ // closed) is written to the run so it is not asked again; open is.
2609
+ const meta = await store.readMeta(prStateMatch[1]).catch(() => null);
2610
+ if (!meta?.git?.pr) return json(res, 404, { error: 'no pull request on this run' });
2611
+ if (meta.git.prState) return json(res, 200, { url: meta.git.pr, state: meta.git.prState, cached: true });
2612
+ const s = await pullRequestState(meta.folder, meta.git.pr);
2613
+ if (s && s.state !== 'open') {
2614
+ meta.git = { ...meta.git, prState: s.state };
2615
+ await store.writeMeta(meta).catch(() => {});
2616
+ }
2617
+ json(res, 200, { url: meta.git.pr, state: s?.state ?? null, mergedAt: s?.mergedAt, number: s?.number });
2618
+
2619
+ } else if (prMatch && req.method === 'GET') {
2620
+ // The pull request as Foreman would draft it, for the sheet to edit.
2621
+ const meta = await store.readMeta(prMatch[1]).catch(() => null);
2622
+ if (!meta) return json(res, 404, { error: 'unknown run' });
2623
+ if (!meta.git) return json(res, 409, { error: 'this run had no branch of its own' });
2624
+ const info = await gitInfo(meta.folder);
2625
+ if (!info.remote) return json(res, 409, { error: 'the repository has no origin remote to push to' });
2626
+ const doc = await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null);
2627
+ const gh = await ghReady();
2628
+ json(res, 200, {
2629
+ ...prDraft(meta, doc), branch: meta.git.branch, base: meta.git.base, commits: meta.git.commits ?? null,
2630
+ remote: info.remote, compareUrl: compareUrl(info.remote, meta.git.base, meta.git.branch),
2631
+ gh, pr: meta.git.pr ?? null, onBranch: info.branch === meta.git.branch, dirty: Boolean(info.dirty),
2632
+ });
2633
+
2634
+ } else if (prMatch && req.method === 'POST') {
2635
+ // The one outward-facing act: push the mission's branch and open the
2636
+ // pull request — as the user, with their git and gh, on their click.
2637
+ // Never from an agent, never from the phone.
2638
+ const meta = await store.readMeta(prMatch[1]).catch(() => null);
2639
+ if (!meta) return json(res, 404, { error: 'unknown run' });
2640
+ if (!meta.git) return json(res, 409, { error: 'this run had no branch of its own' });
2641
+ if (meta.status === 'running') return json(res, 409, { error: 'the mission is still running' });
2642
+ const { title, body } = await readBody(req);
2643
+ const t = typeof title === 'string' && title.trim() ? title.trim().slice(0, 200) : null;
2644
+ if (!t) return json(res, 400, { error: 'a title is required' });
2645
+ const b = typeof body === 'string' ? body : '';
2646
+ const info = await gitInfo(meta.folder);
2647
+ if (!info.remote) return json(res, 409, { error: 'the repository has no origin remote to push to' });
2648
+ const emit = makeEmitter(meta.id, meta.projectId ?? prMatch[1]);
2649
+ const pushed = await pushBranch(meta.folder, meta.git.branch);
2650
+ if (pushed) {
2651
+ emit('pull_request', { branch: meta.git.branch, error: pushed, text: `Push of ${meta.git.branch} failed: ${pushed}` });
2652
+ return json(res, 502, { error: pushed });
2653
+ }
2654
+ const gh = await ghReady();
2655
+ let url: string | undefined;
2656
+ let method: 'gh' | 'compare' = 'compare';
2657
+ let note: string | undefined;
2658
+ if (gh.present && gh.authed && /github\.com/.test(info.remote)) {
2659
+ const r = await createPullRequest(meta.folder, { base: meta.git.base, branch: meta.git.branch, title: t, body: b });
2660
+ if (r.url) { url = r.url; method = 'gh'; } else note = r.error;
2661
+ }
2662
+ if (!url) url = compareUrl(info.remote, meta.git.base, meta.git.branch) ?? undefined;
2663
+ meta.git = { ...meta.git, pr: method === 'gh' ? url : meta.git.pr };
2664
+ await store.writeMeta(meta).catch(() => {});
2665
+ gitInfoCache.delete(meta.folder);
2666
+ const text = method === 'gh'
2667
+ ? `Pushed ${meta.git.branch} and opened a pull request: ${url}`
2668
+ : `Pushed ${meta.git.branch}.${note ? ` ${note}.` : ''} Finish the pull request in the browser: ${url ?? 'open the repository'}`;
2669
+ emit('pull_request', { branch: meta.git.branch, base: meta.git.base, url, method, text });
2670
+ json(res, 200, { ok: true, url, method, pushed: true, note });
2671
+
2217
2672
  } else if (req.method === 'POST' && url.pathname === '/steer') {
2218
2673
  const { runId, text } = await readBody(req);
2219
2674
  const trimmed = typeof text === 'string' ? text.trim() : '';
@@ -2294,6 +2749,12 @@ const server = http.createServer(async (req, res) => {
2294
2749
  if (!events) return json(res, 404, { error: 'unknown run' });
2295
2750
  json(res, 200, { events });
2296
2751
 
2752
+ } else if (req.method === 'GET' && /^\/projects\/[^/]+\/memory$/.test(url.pathname)) {
2753
+ // What the project "believes": .foreman/MEMORY.md, read-only here.
2754
+ const project = await store.getProject(url.pathname.split('/')[2]).catch(() => null);
2755
+ if (!project) return json(res, 404, { error: 'unknown project' });
2756
+ json(res, 200, await readMemory(project.folder));
2757
+
2297
2758
  } else if (req.method === 'GET' && url.pathname === '/missiondoc') {
2298
2759
  const runId = url.searchParams.get('run');
2299
2760
  if (!runId) return json(res, 400, { error: 'run parameter is required' });
@@ -2306,6 +2767,9 @@ const server = http.createServer(async (req, res) => {
2306
2767
  } else if (req.method === 'GET' && url.pathname === '/browse') {
2307
2768
  const requested = url.searchParams.get('path') || os.homedir();
2308
2769
  const dir = path.resolve(requested);
2770
+ if (!pathPermitted(dir, await browseRoots())) {
2771
+ return json(res, 400, { error: 'path outside the folders Foreman may browse' });
2772
+ }
2309
2773
  const st = await stat(dir).catch(() => null);
2310
2774
  if (!st?.isDirectory()) return json(res, 400, { error: `not a directory: ${dir}` });
2311
2775
  const entries = await readdir(dir, { withFileTypes: true });
@@ -2324,6 +2788,11 @@ const server = http.createServer(async (req, res) => {
2324
2788
  if (typeof name !== 'string' || !name.trim() || /[/\\]/.test(name) || name.trim().startsWith('.')) {
2325
2789
  return json(res, 400, { error: 'invalid folder name' });
2326
2790
  }
2791
+ // The parent is what is being written into, so the parent is what has
2792
+ // to be inside the allowed folders.
2793
+ if (!pathPermitted(parent, await browseRoots())) {
2794
+ return json(res, 400, { error: 'path outside the folders Foreman may browse' });
2795
+ }
2327
2796
  const parentSt = await stat(parent).catch(() => null);
2328
2797
  if (!parentSt?.isDirectory()) return json(res, 400, { error: `not a directory: ${parent}` });
2329
2798
  const created = path.join(parent, name.trim());
@@ -2339,12 +2808,42 @@ const server = http.createServer(async (req, res) => {
2339
2808
  json(res, 404, { error: 'not found' });
2340
2809
  }
2341
2810
  } catch (err) {
2811
+ if (err instanceof BodyError) {
2812
+ // The answer goes out before the stream is torn down: destroying the
2813
+ // request first makes the client see a connection reset instead of the
2814
+ // 413 that explains it. `res.end` has already queued the bytes by the
2815
+ // time `req.destroy` stops the sender from writing more.
2816
+ json(res, err.status, { error: err.message });
2817
+ if (!req.readableEnded) req.destroy();
2818
+ return;
2819
+ }
2342
2820
  json(res, 500, { error: String(err) });
2343
2821
  }
2344
2822
  });
2345
2823
 
2824
+ /**
2825
+ * The second origin: nothing but the crew's exposed dev servers, guarded the
2826
+ * same way as the dashboard. Kept separate so a page an agent wrote cannot
2827
+ * reach a single Foreman route from the browser — see src/services.ts.
2828
+ */
2829
+ const servicesServer = http.createServer(servicesHandler({
2830
+ registry: services,
2831
+ allowed: (req) => requestAllowed(req, { port: SERVICES_PORT, tailnet, bindAll: BIND === 'all' }),
2832
+ }));
2833
+ /**
2834
+ * Why the services port is not listening, when it is not. A taken port must
2835
+ * not take the dashboard down with it (an unhandled 'error' on a server is a
2836
+ * crash): it is logged, preflight has already warned, and expose_service
2837
+ * tells the crew plainly instead of handing out a URL that answers nothing.
2838
+ */
2839
+ let servicesDown: string | null = null;
2840
+ servicesServer.on('error', (err: NodeJS.ErrnoException) => {
2841
+ servicesDown = `Foreman's services port ${SERVICES_PORT} is not available (${err.code ?? err.message}); set FOREMAN_SERVICES_PORT to a free port and restart to expose dev servers`;
2842
+ console.warn(`[services] ${servicesDown}`);
2843
+ });
2844
+
2346
2845
  // Fail loudly on a misconfigured install before anything else happens.
2347
- if (!reportPreflight(await preflight({ port: PORT, foremanHome: store.root, distDir: DIST_DIR, tailnet }))) {
2846
+ if (!reportPreflight(await preflight({ port: PORT, servicesPort: SERVICES_PORT, foremanHome: store.root, distDir: DIST_DIR, tailnet }))) {
2348
2847
  process.exit(1);
2349
2848
  }
2350
2849
 
@@ -2389,11 +2888,13 @@ void store.listRuns().then((runs) => {
2389
2888
  }).catch(() => {});
2390
2889
 
2391
2890
  if (BIND === 'all') {
2392
- server.listen(PORT, () => console.log(`Foreman listening on http://0.0.0.0:${PORT} (FOREMAN_BIND=all)`));
2891
+ server.listen(PORT, () => console.log(`Foreman listening on http://0.0.0.0:${PORT} (FOREMAN_BIND=all) · services on http://0.0.0.0:${SERVICES_PORT}`));
2892
+ servicesServer.listen(SERVICES_PORT);
2393
2893
  } else {
2394
2894
  server.listen(PORT, '127.0.0.1', () => {
2395
- console.log(`Foreman listening on http://localhost:${PORT}${tailnet ? ` · ${tailnetUrl(tailnet, PORT)}` : ''}`);
2895
+ console.log(`Foreman listening on http://localhost:${PORT}${tailnet ? ` · ${tailnetUrl(tailnet, PORT)}` : ''} · services on http://localhost:${SERVICES_PORT}`);
2396
2896
  });
2897
+ servicesServer.listen(SERVICES_PORT, '127.0.0.1');
2397
2898
  if (tailnet) {
2398
2899
  // A second listener on the tailnet address, feeding the same handler.
2399
2900
  // Not 0.0.0.0: the café Wi-Fi is not the tailnet.
@@ -2401,7 +2902,12 @@ if (BIND === 'all') {
2401
2902
  const viaTailnet = http.createServer(onRequest);
2402
2903
  viaTailnet.on('error', (err) => console.warn(`[tailscale] could not listen on ${tailnet.ip}:${PORT} — ${err.message}`));
2403
2904
  viaTailnet.listen(PORT, tailnet.ip);
2404
- for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => viaTailnet.close());
2905
+ // The services port needs the same reach: the human opens those previews
2906
+ // from their phone, over the tailnet, like everything else.
2907
+ const svcViaTailnet = http.createServer(servicesServer.listeners('request')[0] as http.RequestListener);
2908
+ svcViaTailnet.on('error', (err) => console.warn(`[tailscale] could not listen on ${tailnet.ip}:${SERVICES_PORT} — ${err.message}`));
2909
+ svcViaTailnet.listen(SERVICES_PORT, tailnet.ip);
2910
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => { viaTailnet.close(); svcViaTailnet.close(); });
2405
2911
  }
2406
2912
  }
2407
2913