@amenophis1er/foreman 0.1.7 → 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,10 +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
27
31
  * GET /search?q= Runs across the fleet matching title, brief, project or folder
28
32
  * POST /fleet/chat One turn with the fleet planner {text} → {text, costUsd}
29
33
  * POST /fleet/stop Stop the fleet planner reply in flight
30
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
31
38
  * POST /steer Send an operator note to a running director {runId, text}
32
39
  * POST /interrupt Interrupt a run {runId}
33
40
  * GET /runs?projectId= Persisted run summaries, newest first
@@ -47,7 +54,7 @@
47
54
  import http from 'node:http';
48
55
  import crypto from 'node:crypto';
49
56
  import os from 'node:os';
50
- import { mkdir, readFile, readdir, stat } from 'node:fs/promises';
57
+ import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
51
58
  import path from 'node:path';
52
59
  import { fileURLToPath } from 'node:url';
53
60
  import { MissionRun } from './orchestrator.js';
@@ -57,10 +64,18 @@ import {
57
64
  dropPendingAsk,
58
65
  } from './planner.js';
59
66
  import { DEFAULT_TOOL_POLICY } from './policy.js';
67
+ import { pathPermitted, requestAllowed } from './guard.js';
68
+ import { BodyError, bodyLimitFor, parseBody } from './http-body.js';
60
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';
61
76
  import { detectTailscale, tailnetUrl } from './tailscale.js';
62
77
  import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
63
- import { ServiceRegistry, SVC_PREFIX, parseServicePath, portOpen, proxyToService, servicePath } from './services.js';
78
+ import { ServiceRegistry, portOpen, servicesHandler } from './services.js';
64
79
  import { HELP_TEXT, expandHome, parseCommand, projectsRoot, slug } from './notify/commands.js';
65
80
  import {
66
81
  DEFAULT_FLEET_MODEL, FLEET_CHAT_ID, PHONE_CONTEXT_MS, phoneRoute, runFleetTurn,
@@ -229,6 +244,8 @@ interface ModelOption {
229
244
  providerLabel: string;
230
245
  cost?: number;
231
246
  note?: string;
247
+ /** Its track record on this machine, from the run ledger; absent until it has one. */
248
+ record?: string;
232
249
  /** What spending on this model is: priced, free, or real-but-unquantified. */
233
250
  costBasis: CostBasis;
234
251
  /** @deprecated Mirrors `costBasis === 'priced'` for older clients. */
@@ -249,6 +266,15 @@ async function availableModels(project: Project | null): Promise<{
249
266
  reachable: boolean;
250
267
  }> {
251
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
+ };
252
278
 
253
279
  // Anthropic, via whichever Claude Code install or key the server resolves.
254
280
  // Always offered: it is the default, and the shipped configuration.
@@ -326,7 +352,7 @@ async function availableModels(project: Project | null): Promise<{
326
352
  count: out.filter((x) => x.providerLabel === m.providerLabel).length,
327
353
  }));
328
354
 
329
- return { models: out, groups, reachable: true };
355
+ return { models: out.map(withRecord), groups, reachable: true };
330
356
  }
331
357
 
332
358
  /**
@@ -347,6 +373,26 @@ function providerForRole(meta: RunMeta, roleProviderId?: string): ProviderRef {
347
373
  return own;
348
374
  }
349
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
+
350
396
  /** Whether a project's provider has a key on file. Never the key itself. */
351
397
  async function providerHasKeyOf(p: Project): Promise<boolean> {
352
398
  const ref = providerOf(p);
@@ -369,7 +415,6 @@ function toPath(v: unknown): string | undefined {
369
415
  return typeof v === 'string' && v.trim() ? v.trim() : undefined;
370
416
  }
371
417
 
372
- /** Parses a model choice: a known alias or a full claude-* id; else inherit. */
373
418
  /**
374
419
  * A model id from any provider.
375
420
  *
@@ -420,6 +465,19 @@ async function locateFolders(name: string): Promise<string[]> {
420
465
  }
421
466
 
422
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
+ })();
423
481
  /**
424
482
  * Where to listen. Default: loopback, plus the tailnet address when this
425
483
  * machine is on one — never every interface, since there is no login.
@@ -452,12 +510,30 @@ const authPromise = detectAuth();
452
510
  const activeByProject = new Map<string, MissionRun | null>();
453
511
  const sseClients = new Set<http.ServerResponse>();
454
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
+
455
532
  function activeRuns(): MissionRun[] {
456
533
  // Filter out reservation placeholders (see reserveProject).
457
534
  return [...activeByProject.values()].filter((r): r is MissionRun => Boolean(r));
458
535
  }
459
536
 
460
- /** Broadcasts an enveloped frame to live clients and persists the bare event. */
461
537
  /**
462
538
  * What happened in the fleet lately, in one line each, for the front desk.
463
539
  *
@@ -560,10 +636,6 @@ function ledgerKeyFor(meta: RunMeta): string {
560
636
  return `${meta.id}.${meta.resumes ?? 0}`;
561
637
  }
562
638
 
563
- /**
564
- * Chat frames carry `chat: true` and no run id, so a UI following the same
565
- * stream can tell a planning conversation from a mission without guessing.
566
- */
567
639
  /**
568
640
  * A chat event for open tabs only, not the log: the log it would describe
569
641
  * is the one being thrown away. Used when a conversation is cleared, so a
@@ -736,6 +808,55 @@ async function findProject(ref: string): Promise<Project | null> {
736
808
  ?? null;
737
809
  }
738
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
+
739
860
  // ---------------------------------------------------------------------------
740
861
  // The fleet planner — the front desk
741
862
  // ---------------------------------------------------------------------------
@@ -851,6 +972,8 @@ const fleetHost: FleetHost = {
851
972
  if (words.error) lines.push(`it stopped with: ${clipText(words.error, 300)}`);
852
973
  }
853
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` : ''}`);
854
977
  const meta = await store.readChatMeta(project.id).catch(() => null);
855
978
  if (meta?.proposal) lines.push(`a proposal is waiting for Start or Discard: "${clipText(firstLine(meta.proposal.mission), 100)}" cap $${meta.proposal.budgetUsd}`);
856
979
  if (chatTurns.has(project.id)) lines.push('its planner is replying right now');
@@ -889,6 +1012,11 @@ const fleetHost: FleetHost = {
889
1012
  },
890
1013
 
891
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
+ }
892
1020
  const folder = expandHome(folderIn.trim());
893
1021
  if (!path.isAbsolute(folder)) return `A folder to link must be an absolute path (or ~/…), not "${folderIn}".`;
894
1022
  const st = await stat(folder).catch(() => null);
@@ -950,7 +1078,7 @@ async function driveFleetTurn(text: string, via: 'telegram' | 'http'): Promise<{
950
1078
  try {
951
1079
  const resolved = await resolveProvider(providerOf({}), store.root);
952
1080
  emit('chat_turn', { state: 'thinking', model, provider: resolved.label, costBasis: resolved.costBasis });
953
- const problem = providerProblem(resolved);
1081
+ const problem = providerProblem(resolved) ?? await signedInProblem(resolved);
954
1082
  if (problem) {
955
1083
  emit('chat_error', { error: `provider unavailable — ${problem}` });
956
1084
  return { text: '', costUsd: 0, error: `provider unavailable — ${problem}` };
@@ -964,7 +1092,7 @@ async function driveFleetTurn(text: string, via: 'telegram' | 'http'): Promise<{
964
1092
  const result = await runFleetTurn({
965
1093
  sessionId: meta.sessionId, text, model, cwd, host: fleetHost, via,
966
1094
  news: fleetNews(since), sinceMs: Date.now() - since,
967
- 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 })),
968
1096
  agentEnv: await agentEnvFor(resolved, `chat:${FLEET_CHAT_ID}`),
969
1097
  emit, abort,
970
1098
  });
@@ -1068,6 +1196,13 @@ async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
1068
1196
  return say(`<b>Running · ${live.length}</b>\n${lines.join('\n')}${planning.length ? `\n\n<b>Planning</b>\n${planning.join('\n')}` : ''}`);
1069
1197
  }
1070
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
+ }
1071
1206
  const settings = await store.readSettings().catch(() => ({ global: {}, projects: {} }));
1072
1207
  const root = projectsRoot((settings.global as Record<string, unknown>).projectsRoot);
1073
1208
  const name = slug(cmd.name);
@@ -1138,11 +1273,6 @@ async function chatMetaOf(projectId: string): Promise<ChatMeta> {
1138
1273
  ?? { projectId, costUsd: 0, createdAt: now, updatedAt: now };
1139
1274
  }
1140
1275
 
1141
- /**
1142
- * Runs one planning turn: the human's message goes into the log first (so a
1143
- * reload mid-turn still shows what was asked), then the planner's reply
1144
- * streams out through the same envelope machinery as a mission.
1145
- */
1146
1276
  /**
1147
1277
  * `shown` is what the transcript records as the human's message when it
1148
1278
  * differs from what the planner is sent (a fork's seed). `via: 'telegram'`
@@ -1182,7 +1312,7 @@ async function driveChatTurn(project: Project, text: string, shown: string = tex
1182
1312
  provider: resolved.label,
1183
1313
  costBasis: resolved.costBasis,
1184
1314
  });
1185
- const problem = providerProblem(resolved);
1315
+ const problem = providerProblem(resolved) ?? await signedInProblem(resolved);
1186
1316
  if (problem) {
1187
1317
  emit('chat_error', { error: `provider unavailable — ${problem}` });
1188
1318
  return;
@@ -1197,8 +1327,9 @@ async function driveChatTurn(project: Project, text: string, shown: string = tex
1197
1327
  projectId: project.id,
1198
1328
  models: models.map((m) => ({
1199
1329
  id: m.id, label: m.label, providerId: m.providerId,
1200
- providerLabel: m.providerLabel, costBasis: m.costBasis, note: m.note,
1330
+ providerLabel: m.providerLabel, costBasis: m.costBasis, note: m.note, record: m.record,
1201
1331
  })),
1332
+ anchor: await budgetAnchorFor(project).catch(() => ''),
1202
1333
  sessionId: meta.sessionId,
1203
1334
  folder: project.folder,
1204
1335
  text,
@@ -1244,18 +1375,25 @@ async function driveChatTurn(project: Project, text: string, shown: string = tex
1244
1375
  }
1245
1376
 
1246
1377
  /**
1247
- * A mission has started, so the proposal that led to it is spent. Recording
1248
- * the handoff in the conversation matters as much as clearing it: the chat is
1249
- * the story of how this mission came to exist, and it should not simply stop
1250
- * 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.
1251
1387
  */
1252
1388
  async function consumeProposal(projectId: string, runId: string, mission: string): Promise<void> {
1253
1389
  const meta = await store.readChatMeta(projectId).catch(() => null);
1254
1390
  if (!meta) return;
1255
- makeChatEmitter(projectId)('mission_started', { runId, mission });
1256
- if (!meta.proposal) return;
1257
- const { proposal: _spent, ...rest } = meta;
1258
- 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', {});
1259
1397
  }
1260
1398
 
1261
1399
  /**
@@ -1269,6 +1407,28 @@ function reserveProject(projectId: string): boolean {
1269
1407
  return true;
1270
1408
  }
1271
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
+
1272
1432
  /** Runs a mission to completion. The project must already be reserved. */
1273
1433
  async function driveRun(
1274
1434
  projectId: string, meta: RunMeta, resume?: { sessionId?: string },
@@ -1279,7 +1439,7 @@ async function driveRun(
1279
1439
  // A run that cannot resolve a credential must not start: dispatching anyway
1280
1440
  // would fall back to whatever the environment happens to hold.
1281
1441
  const resolved = await resolveProvider(providerOf(meta), store.root);
1282
- const problem = providerProblem(resolved);
1442
+ const problem = providerProblem(resolved) ?? await signedInProblem(resolved);
1283
1443
  if (problem) {
1284
1444
  meta.status = 'error';
1285
1445
  meta.endedAt = Date.now();
@@ -1289,6 +1449,29 @@ async function driveRun(
1289
1449
  activeByProject.delete(projectId);
1290
1450
  return;
1291
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
+ }
1292
1475
  let agentEnv;
1293
1476
  let roleBasis = resolved.costBasis;
1294
1477
  let prices: { director?: ModelPrice; worker?: ModelPrice } = {};
@@ -1373,11 +1556,14 @@ async function driveRun(
1373
1556
  // A dev server behind Foreman's address. Declared ports only, and only
1374
1557
  // ones something is listening on — an agent cannot reserve a path for a
1375
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,
1376
1562
  exposeService: async (runId, port, label) => {
1563
+ if (servicesDown) return { ok: false, reason: servicesDown };
1377
1564
  if (!(await portOpen(port))) return { ok: false, reason: `nothing is listening on 127.0.0.1:${port} — start the server first` };
1378
1565
  const svc = services.register(runId, port, label);
1379
- const base = (await notifySettings().catch(() => null))?.publicUrl ?? (tailnet ? tailnetUrl(tailnet, PORT) : `http://localhost:${PORT}`);
1380
- return { ok: true, url: `${base.replace(/\/+$/, '')}${svc.path}`, path: svc.path };
1566
+ return { ok: true, url: `${await servicesBase()}${svc.path}`, path: svc.path };
1381
1567
  },
1382
1568
  });
1383
1569
  activeByProject.set(projectId, run);
@@ -1409,6 +1595,23 @@ async function driveRun(
1409
1595
  // However the run ended, it no longer needs its gateways.
1410
1596
  releaseGateways(meta.id);
1411
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
+ }
1412
1615
  }
1413
1616
  }
1414
1617
 
@@ -1418,6 +1621,8 @@ async function effectiveSettings(projectId: string): Promise<{
1418
1621
  directorModel: ModelChoice; workerModel: ModelChoice; plannerModel: ModelChoice;
1419
1622
  /** Provider serving each role, when Settings pinned one with the model. */
1420
1623
  directorProviderId?: string; workerProviderId?: string;
1624
+ /** In a repository, each mission runs on a branch of its own (default on). */
1625
+ gitBranchPerMission: boolean;
1421
1626
  }> {
1422
1627
  const s = await store.readSettings()
1423
1628
  .catch(() => ({ global: {}, projects: {} as Record<string, object> }));
@@ -1438,9 +1643,26 @@ async function effectiveSettings(projectId: string): Promise<{
1438
1643
  plannerModel: modelChoice(p.plannerModel ?? g.plannerModel),
1439
1644
  directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
1440
1645
  workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
1646
+ gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
1441
1647
  };
1442
1648
  }
1443
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
+
1444
1666
  async function startRun(
1445
1667
  projectId: string, folder: string, mission: string, budgetUsd: number,
1446
1668
  directorModel: ModelChoice, workerModel: ModelChoice, browserTools: boolean,
@@ -1477,6 +1699,21 @@ async function startRun(
1477
1699
  return;
1478
1700
  }
1479
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
+ }
1480
1717
  await driveRun(projectId, meta);
1481
1718
  }
1482
1719
 
@@ -1514,6 +1751,11 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
1514
1751
  meta.status = 'running';
1515
1752
  meta.endedAt = undefined;
1516
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
+ }
1517
1759
  await store.writeMeta(meta).catch((err) => {
1518
1760
  console.error(`failed to persist resume of ${meta.id}:`, err);
1519
1761
  });
@@ -1558,13 +1800,52 @@ async function serveStatic(pathname: string, res: http.ServerResponse): Promise<
1558
1800
  // Request helpers
1559
1801
  // ---------------------------------------------------------------------------
1560
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
+ */
1561
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');
1562
1817
  const chunks: Buffer[] = [];
1563
- 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
+ }
1564
1825
  if (!chunks.length) return {};
1565
- const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString());
1566
- return typeof parsed === 'object' && parsed !== null
1567
- ? (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;
1568
1849
  }
1569
1850
 
1570
1851
  function json(res: http.ServerResponse, code: number, body: unknown): void {
@@ -1577,40 +1858,31 @@ function json(res: http.ServerResponse, code: number, body: unknown): void {
1577
1858
  // ---------------------------------------------------------------------------
1578
1859
 
1579
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; }
1580
1868
  const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
1581
- // Services the crew exposed: /svc/<run>/<port>/… goes to 127.0.0.1:<port>,
1582
- // but only for a pair a run declared. A page served this way asks for its
1583
- // absolute-path assets (`/app.js`) against Foreman's root; those arrive as
1584
- // sub-resource requests carrying the service page as Referer, and are
1585
- // routed to the same service. Documents never are — a typed URL is Foreman's.
1586
- {
1587
- const svc = parseServicePath(url.pathname);
1588
- if (svc) {
1589
- if (!services.has(svc.runId, svc.port)) { json(res, 404, { error: 'no such service' }); return; }
1590
- proxyToService(req, res, svc.port, svc.rest, url.search, servicePath(svc.runId, svc.port));
1591
- return;
1592
- }
1593
- const ref = req.headers.referer;
1594
- const dest = String(req.headers['sec-fetch-dest'] ?? '');
1595
- if (ref && dest && dest !== 'document' && dest !== 'empty' && !url.pathname.startsWith(SVC_PREFIX)) {
1596
- try {
1597
- const via = parseServicePath(new URL(ref).pathname);
1598
- if (via && services.has(via.runId, via.port)) {
1599
- proxyToService(req, res, via.port, url.pathname, url.search, servicePath(via.runId, via.port));
1600
- return;
1601
- }
1602
- } catch { /* not a URL we can read — fall through to Foreman's own routes */ }
1603
- }
1604
- }
1869
+ // Note: /svc/… is not handled here. Exposed dev servers live on
1870
+ // SERVICES_PORT, on their own origin — see servicesServer below.
1605
1871
  const runEventsMatch = url.pathname.match(/^\/runs\/([^/]+)\/events$/);
1606
1872
  // The deck: what a run changed and what it produced. Read-only by design —
1607
1873
  // a stated non-goal — and handled before the chain because it owns two paths
1608
1874
  // under /runs/{id}/ that nothing else claims.
1609
- 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
+ }
1610
1880
  const m = await store.readMeta(id).catch(() => null);
1611
1881
  return m ? { folder: m.folder } : null;
1612
1882
  })) return;
1613
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$/);
1614
1886
  const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
1615
1887
  const providerKeyMatch = url.pathname.match(/^\/providers\/([A-Za-z0-9_-]{1,64})\/key$/);
1616
1888
 
@@ -1632,6 +1904,57 @@ const server = http.createServer(async (req, res) => {
1632
1904
  sseClients.add(res);
1633
1905
  req.on('close', () => sseClients.delete(res));
1634
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
+
1635
1958
  } else if (req.method === 'GET' && url.pathname === '/settings') {
1636
1959
  json(res, 200, await store.readSettings());
1637
1960
 
@@ -1665,9 +1988,14 @@ const server = http.createServer(async (req, res) => {
1665
1988
  json(res, 200, await availableModels(project));
1666
1989
 
1667
1990
  } else if (req.method === 'GET' && url.pathname === '/projects') {
1668
- const [projects, allRuns, auth] = await Promise.all([
1991
+ const [projects, allRuns, auth, settingsFile] = await Promise.all([
1669
1992
  store.listProjects(), store.listRuns(), authPromise,
1993
+ store.readSettings().catch(() => ({ global: {} as Record<string, unknown>, projects: {} })),
1670
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;
1671
1999
  const cards = await Promise.all(projects.map(async (p) => {
1672
2000
  const run = activeByProject.get(p.id);
1673
2001
  const plannerAsk = pendingChatQuestion(p.id);
@@ -1681,6 +2009,9 @@ const server = http.createServer(async (req, res) => {
1681
2009
  // Whether a key is on file, never the key. Settings renders
1682
2010
  // "stored / not stored" from this and nothing more.
1683
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),
1684
2015
  activeRun: run ? { ...run.meta } : null,
1685
2016
  lastRun: lastRun && {
1686
2017
  id: lastRun.id,
@@ -1720,6 +2051,7 @@ const server = http.createServer(async (req, res) => {
1720
2051
  json(res, 200, {
1721
2052
  // Billing mode travels with every fleet poll so the UI can state it
1722
2053
  // plainly wherever money is about to be spent.
2054
+ firstRun,
1723
2055
  authMode: auth.mode,
1724
2056
  authSource: auth.source,
1725
2057
  authAccount: auth.account ?? null,
@@ -2194,6 +2526,42 @@ const server = http.createServer(async (req, res) => {
2194
2526
  if (!ok) return json(res, 404, { error: 'no pending question with that id' });
2195
2527
  json(res, 200, { ok: true });
2196
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
+
2197
2565
  } else if (req.method === 'GET' && url.pathname === '/search') {
2198
2566
  // Every run across the fleet whose title, brief, project or folder
2199
2567
  // says the words. Run records are small and already on disk; no index.
@@ -2235,6 +2603,72 @@ const server = http.createServer(async (req, res) => {
2235
2603
  broadcastChat(FLEET_CHAT_ID, 'chat_cleared', {});
2236
2604
  json(res, 200, { ok: true });
2237
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
+
2238
2672
  } else if (req.method === 'POST' && url.pathname === '/steer') {
2239
2673
  const { runId, text } = await readBody(req);
2240
2674
  const trimmed = typeof text === 'string' ? text.trim() : '';
@@ -2315,6 +2749,12 @@ const server = http.createServer(async (req, res) => {
2315
2749
  if (!events) return json(res, 404, { error: 'unknown run' });
2316
2750
  json(res, 200, { events });
2317
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
+
2318
2758
  } else if (req.method === 'GET' && url.pathname === '/missiondoc') {
2319
2759
  const runId = url.searchParams.get('run');
2320
2760
  if (!runId) return json(res, 400, { error: 'run parameter is required' });
@@ -2327,6 +2767,9 @@ const server = http.createServer(async (req, res) => {
2327
2767
  } else if (req.method === 'GET' && url.pathname === '/browse') {
2328
2768
  const requested = url.searchParams.get('path') || os.homedir();
2329
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
+ }
2330
2773
  const st = await stat(dir).catch(() => null);
2331
2774
  if (!st?.isDirectory()) return json(res, 400, { error: `not a directory: ${dir}` });
2332
2775
  const entries = await readdir(dir, { withFileTypes: true });
@@ -2345,6 +2788,11 @@ const server = http.createServer(async (req, res) => {
2345
2788
  if (typeof name !== 'string' || !name.trim() || /[/\\]/.test(name) || name.trim().startsWith('.')) {
2346
2789
  return json(res, 400, { error: 'invalid folder name' });
2347
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
+ }
2348
2796
  const parentSt = await stat(parent).catch(() => null);
2349
2797
  if (!parentSt?.isDirectory()) return json(res, 400, { error: `not a directory: ${parent}` });
2350
2798
  const created = path.join(parent, name.trim());
@@ -2360,12 +2808,42 @@ const server = http.createServer(async (req, res) => {
2360
2808
  json(res, 404, { error: 'not found' });
2361
2809
  }
2362
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
+ }
2363
2820
  json(res, 500, { error: String(err) });
2364
2821
  }
2365
2822
  });
2366
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
+
2367
2845
  // Fail loudly on a misconfigured install before anything else happens.
2368
- 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 }))) {
2369
2847
  process.exit(1);
2370
2848
  }
2371
2849
 
@@ -2410,11 +2888,13 @@ void store.listRuns().then((runs) => {
2410
2888
  }).catch(() => {});
2411
2889
 
2412
2890
  if (BIND === 'all') {
2413
- 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);
2414
2893
  } else {
2415
2894
  server.listen(PORT, '127.0.0.1', () => {
2416
- 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}`);
2417
2896
  });
2897
+ servicesServer.listen(SERVICES_PORT, '127.0.0.1');
2418
2898
  if (tailnet) {
2419
2899
  // A second listener on the tailnet address, feeding the same handler.
2420
2900
  // Not 0.0.0.0: the café Wi-Fi is not the tailnet.
@@ -2422,7 +2902,12 @@ if (BIND === 'all') {
2422
2902
  const viaTailnet = http.createServer(onRequest);
2423
2903
  viaTailnet.on('error', (err) => console.warn(`[tailscale] could not listen on ${tailnet.ip}:${PORT} — ${err.message}`));
2424
2904
  viaTailnet.listen(PORT, tailnet.ip);
2425
- 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(); });
2426
2911
  }
2427
2912
  }
2428
2913