@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.
@@ -90,11 +90,13 @@ class MessageStream implements AsyncIterable<SDKUserMessage> {
90
90
  }
91
91
  }
92
92
  import { WORK_DIR, makePolicy, type PendingPermission } from './policy.js';
93
+ import { PLAYWRIGHT_MCP_CLI } from './browser.js';
93
94
  import type { AgentEnv } from './provider.js';
94
95
  import { generateRunTitle } from './title.js';
95
96
  import { combineBasis, costBasisOf, isPriced, type CostBasis } from './types.js';
96
97
  import { priceUsage, type ModelPrice } from './prices.js';
97
98
  import { captureBaseline } from './deck.js';
99
+ import { memorySection, readMemory, writeMemory } from './memory.js';
98
100
  import type { RunMeta, TokenUsage, WorkerMeta, WorkerProgress } from './types.js';
99
101
 
100
102
  /** A run's usage before its first `result` message. */
@@ -248,6 +250,32 @@ export async function ensureIgnoreLines(file: string, lines: readonly string[]):
248
250
  // a metered one that dollars already stop.
249
251
  const DEFAULT_MAX_TURNS = 150;
250
252
 
253
+ /**
254
+ * The same idea counted in tokens, for the same reason the turn cap exists.
255
+ *
256
+ * A turn cap bounds how many times the director speaks, not how much it says,
257
+ * and those come apart badly on a free or unpriced run: 150 turns over a large
258
+ * context is millions of tokens that nothing here was watching. It binds only
259
+ * where dollars cannot — a priced run already has a real cap, and this one
260
+ * must never end it first. Sized from the ledger, not from a hunch: cache
261
+ * reads are most of a director loop's traffic, and finished missions on this
262
+ * machine have run to 8–16M tokens total (a 5M figure was first proposed and
263
+ * would have cut several of them short). 20M is clear of every honest run
264
+ * seen so far and still well inside what a runaway reaches before the clock.
265
+ */
266
+ export const DEFAULT_MAX_TOKENS = 20_000_000;
267
+
268
+ /**
269
+ * The token cap as a director should read it: "5M tokens", not "5000000".
270
+ * A budget is only useful if the agent it constrains can hold it in mind, and
271
+ * a raw seven-digit figure in a prompt is one more thing to misread.
272
+ */
273
+ export function tokenCapLabel(n: number): string {
274
+ if (n >= 1e6) return `${Number((n / 1e6).toFixed(1))}M tokens`;
275
+ if (n >= 1000) return `${Number((n / 1000).toFixed(1))}k tokens`;
276
+ return `${n} tokens`;
277
+ }
278
+
251
279
  /**
252
280
  * How long a worker may say nothing at all before it is treated as stalled.
253
281
  *
@@ -675,6 +703,12 @@ directing worker agents. Non-negotiable rules, in priority order:
675
703
  failure is an escalation, never a self-repair.
676
704
  7. When DONE WHEN is verified, update MISSION.md (all boxes ticked, final log
677
705
  entry) and end with a short summary of what was built and how you verified it.
706
+ 8. LEAVE NOTES FOR THE NEXT CREW. Before you finish, call mcp__foreman__remember
707
+ with the whole project memory as it should read now: how to run and test
708
+ the project, ports and paths that matter, conventions and the reasons
709
+ behind them, traps you fell into. Facts, one line each, a page at most.
710
+ Rewrite stale lines rather than appending; drop what no longer holds.
711
+ Never a secret, never anything outside this project.
678
712
  `;
679
713
 
680
714
  export const WORKER_CHARTER = `
@@ -697,9 +731,6 @@ When finished, end with a concise report of what you did and how you checked it.
697
731
  `;
698
732
 
699
733
  /** Foreman's bundled Playwright MCP server, resolved from this repo. */
700
- const PLAYWRIGHT_MCP_CLI = fileURLToPath(
701
- new URL('../node_modules/@playwright/mcp/cli.js', import.meta.url),
702
- );
703
734
 
704
735
  /** Claude subscription/quota exhaustion — an external pause, not a failure. */
705
736
  const USAGE_LIMIT_RE = /out of usage credits|usage limit reached|upgrade to increase your usage/i;
@@ -756,6 +787,8 @@ export class MissionRun {
756
787
  private readonly askTimers = new Map<string, { cancel(): void }>();
757
788
  /** The director's streaming prompt; steering pushes into it. */
758
789
  private directorInput?: MessageStream;
790
+ /** The project's notes as a worker-prompt section, read once per start; empty when there are none. */
791
+ private memoryForWorkers = '';
759
792
  /** Director cost is cumulative per query; track the last figure for deltas. */
760
793
  private directorCostSeen = 0;
761
794
  private wasInterrupted = false;
@@ -834,6 +867,8 @@ export class MissionRun {
834
867
  */
835
868
  private readonly host: {
836
869
  exposeService?: (runId: string, port: number, label: string) => Promise<{ ok: true; url: string; path: string } | { ok: false; reason: string }>;
870
+ /** The browser channel detected at dispatch (src/browser.ts); Chrome when the host says nothing. */
871
+ browserChannel?: string;
837
872
  } = {},
838
873
  ) {
839
874
  this.meta = meta;
@@ -1108,9 +1143,12 @@ export class MissionRun {
1108
1143
  'actually complete. Keep completed work; do not rewrite files that already ' +
1109
1144
  'satisfy their milestone. Update the doc to match reality, then continue ' +
1110
1145
  'the mission to DONE WHEN. ' +
1111
- this.budgetNote()
1146
+ this.gitLine() +
1147
+ this.budgetNote() +
1148
+ memorySection((await readMemory(this.meta.folder)).text, 'director')
1112
1149
  : `MISSION: ${this.meta.mission}\n\n${this.budgetLine()} ` +
1113
- `Working directory: ${this.meta.folder}. Begin by writing .foreman/MISSION.md, then execute the plan.`;
1150
+ `Working directory: ${this.meta.folder}. ${this.gitLine()}Begin by writing .foreman/MISSION.md, then execute the plan.` +
1151
+ memorySection((await readMemory(this.meta.folder)).text, 'director');
1114
1152
 
1115
1153
  try {
1116
1154
  // The mission doc directory ignores itself wholesale (`*`, which also
@@ -1135,6 +1173,7 @@ export class MissionRun {
1135
1173
  void captureBaseline(this.meta.folder, this.meta.id).catch(() => {});
1136
1174
  }
1137
1175
  await mkdir(path.join(this.meta.folder, WORK_DIR), { recursive: true }).catch(() => {});
1176
+ this.memoryForWorkers = memorySection((await readMemory(this.meta.folder)).text, 'worker');
1138
1177
  // Covers a .claude/ left by an earlier run; the one this run creates is
1139
1178
  // handled again on the way out.
1140
1179
  await this.ignoreLocalSettings();
@@ -1360,9 +1399,9 @@ export class MissionRun {
1360
1399
  // Absolute path: the server runs with the mission folder as cwd,
1361
1400
  // where npx cannot resolve Foreman's own dependency.
1362
1401
  command: process.execPath,
1363
- // Chrome channel by default (the machine's own Chrome, no download);
1364
- // FOREMAN_BROWSER picks another channel or Playwright's Chromium.
1365
- args: [PLAYWRIGHT_MCP_CLI, '--headless', '--isolated', '--browser', process.env.FOREMAN_BROWSER || 'chrome'],
1402
+ // The channel the server detected for this machine: FOREMAN_BROWSER,
1403
+ // else its Chrome, else Playwright's own Chromium (src/browser.ts).
1404
+ args: [PLAYWRIGHT_MCP_CLI, '--headless', '--isolated', '--browser', this.host.browserChannel ?? process.env.FOREMAN_BROWSER ?? 'chrome'],
1366
1405
  },
1367
1406
  };
1368
1407
  }
@@ -1441,14 +1480,6 @@ export class MissionRun {
1441
1480
  }
1442
1481
 
1443
1482
 
1444
- /**
1445
- * Fold in the SDK's own dollar figure for a role.
1446
- *
1447
- * Ignored outright where Foreman holds that role's real rates: the SDK
1448
- * prices every response with Anthropic's table, so on a gateway role its
1449
- * number is fiction — and adding fiction to a figure computed from the
1450
- * endpoint's own published rates would corrupt the one honest total.
1451
- */
1452
1483
  /**
1453
1484
  * `costUsd` from its parts. The upstream's own figure, once it has given
1454
1485
  * one, replaces the rated figure for gateway tokens rather than adding to
@@ -1475,12 +1506,6 @@ export class MissionRun {
1475
1506
  this.enforceBudget();
1476
1507
  }
1477
1508
 
1478
- /**
1479
- * Folds one result message's token usage into the run total and persists
1480
- * it. Called alongside addCost() from the same two call sites (director
1481
- * loop, runWorker) so usage and cost are always in step — the honest
1482
- * counterpart to a dollar figure that is not honest on every provider.
1483
- */
1484
1509
  /**
1485
1510
  * The one event that carries a run's economics. Emitted whenever either half
1486
1511
  * changes — dollars OR tokens — because through a gateway the SDK often
@@ -1621,6 +1646,15 @@ export class MissionRun {
1621
1646
  }
1622
1647
  }
1623
1648
 
1649
+ /** The mission's branch, when it has one: stay on it, and leave merging and pushing alone. */
1650
+ private gitLine(): string {
1651
+ const g = this.meta.git;
1652
+ if (!g) return '';
1653
+ return `This mission runs on git branch ${g.branch}, created for it from ${g.base}. Stay on it: do not switch branches, ` +
1654
+ 'do not merge, do not push, do not rebase or reset. You may commit as you go; Foreman commits whatever ' +
1655
+ 'is left uncommitted when the mission ends. ';
1656
+ }
1657
+
1624
1658
  /**
1625
1659
  * What the director is told about its budget, in the units that are true.
1626
1660
  *
@@ -1633,18 +1667,19 @@ export class MissionRun {
1633
1667
  */
1634
1668
  private budgetLine(): string {
1635
1669
  const turns = this.meta.maxTurns ?? DEFAULT_MAX_TURNS;
1670
+ const tokens = tokenCapLabel(this.meta.maxTokens ?? DEFAULT_MAX_TOKENS);
1636
1671
  switch (costBasisOf(this.meta)) {
1637
1672
  case 'free':
1638
1673
  return `This run costs nothing per token — it is served by hardware the ` +
1639
1674
  `operator already owns — so there is no spend cap. It is bounded by ` +
1640
- `${turns} director turns.`;
1675
+ `${turns} director turns and ${tokens}.`;
1641
1676
  case 'unpriced':
1642
1677
  // Deliberately still "no spend cap", and deliberately not silent about
1643
1678
  // the spend. A director told only that money is being spent, with no
1644
1679
  // figure and no cap, invents a limit and winds itself down early.
1645
1680
  return `This run does draw on a paid account, but Foreman cannot price it, ` +
1646
1681
  `so there is no dollar cap and no figure to reason about — do not ration ` +
1647
- `yourself against one. It is bounded by ${turns} director turns.`;
1682
+ `yourself against one. It is bounded by ${turns} director turns and ${tokens}.`;
1648
1683
  case 'priced':
1649
1684
  return `Budget: $${this.meta.budgetUsd.toFixed(2)} total for this run.`;
1650
1685
  }
@@ -1714,10 +1749,21 @@ export class MissionRun {
1714
1749
  return `TIME CAP REACHED: ${Math.round(elapsed / 60)} minutes.`;
1715
1750
  }
1716
1751
  // Money only binds where the figure is real. Enforcing it through a
1717
- // gateway ends working runs over spend that never happened.
1718
- if (!isPriced(this.meta)) return null;
1719
- if (this.meta.costUsd < this.meta.budgetUsd) return null;
1720
- return `BUDGET CAP REACHED: $${this.meta.costUsd.toFixed(2)} of $${this.meta.budgetUsd.toFixed(2)}.`;
1752
+ // gateway ends working runs over spend that never happened. Where it is
1753
+ // real it is the cap, full stop: a priced run is never ended by tokens,
1754
+ // which would cut a mission the human funded to its dollar figure.
1755
+ if (isPriced(this.meta)) {
1756
+ if (this.meta.costUsd < this.meta.budgetUsd) return null;
1757
+ return `BUDGET CAP REACHED: $${this.meta.costUsd.toFixed(2)} of $${this.meta.budgetUsd.toFixed(2)}.`;
1758
+ }
1759
+ // Unpriced or free: tokens are the bound dollars cannot be. Live usage
1760
+ // rather than the persisted total, so a gateway's interim tokens count too.
1761
+ const u = this.liveUsage();
1762
+ const total = u.inputTokens + u.outputTokens + u.cacheReadTokens + u.cacheWriteTokens;
1763
+ if (total >= (this.meta.maxTokens ?? DEFAULT_MAX_TOKENS)) {
1764
+ return `TOKEN CAP REACHED: ${(total / 1e6).toFixed(1)}M tokens.`;
1765
+ }
1766
+ return null;
1721
1767
  }
1722
1768
 
1723
1769
  private overBudget(): string | null {
@@ -1768,22 +1814,6 @@ export class MissionRun {
1768
1814
  );
1769
1815
  }
1770
1816
 
1771
- /**
1772
- * Starts a worker and returns at once; the session runs in the background.
1773
- *
1774
- * The split between this and {@link runWorker} is the asynchronous design in
1775
- * one place: everything the director can observe about a worker — the record
1776
- * in the map, `worker_started`, the `done` promise wait_for_worker races, the
1777
- * stored report and `worker_finished` at the end — is settled here, around a
1778
- * runWorker that only drives the SDK session. The promise is kept on the
1779
- * record rather than dropped, so a worker is never a floating promise, and
1780
- * the outcome is written onto the record rather than returned once, because
1781
- * the director now reads it back whenever it asks.
1782
- *
1783
- * Synchronous up to the point runWorker takes over: by the time this returns
1784
- * the record exists and `worker_started` has been emitted, which is exactly
1785
- * the guarantee spawn_worker's immediate reply relies on.
1786
- */
1787
1817
  /**
1788
1818
  * Item 6, decided as "ask": a worker on a gateway provider has stalled or
1789
1819
  * looped. Rather than letting the director cope alone or retrying somewhere
@@ -1860,6 +1890,22 @@ export class MissionRun {
1860
1890
  };
1861
1891
  }
1862
1892
 
1893
+ /**
1894
+ * Starts a worker and returns at once; the session runs in the background.
1895
+ *
1896
+ * The split between this and {@link runWorker} is the asynchronous design in
1897
+ * one place: everything the director can observe about a worker — the record
1898
+ * in the map, `worker_started`, the `done` promise wait_for_worker races, the
1899
+ * stored report and `worker_finished` at the end — is settled here, around a
1900
+ * runWorker that only drives the SDK session. The promise is kept on the
1901
+ * record rather than dropped, so a worker is never a floating promise, and
1902
+ * the outcome is written onto the record rather than returned once, because
1903
+ * the director now reads it back whenever it asks.
1904
+ *
1905
+ * Synchronous up to the point runWorker takes over: by the time this returns
1906
+ * the record exists and `worker_started` has been emitted, which is exactly
1907
+ * the guarantee spawn_worker's immediate reply relies on.
1908
+ */
1863
1909
  private launchWorker(workerId: string, prompt: string, resumeSessionId?: string, overrides?: WorkerOverrides): WorkerRuntime {
1864
1910
  const existing = this.workers.get(workerId);
1865
1911
  const w: WorkerRuntime = existing ?? {
@@ -2137,7 +2183,9 @@ export class MissionRun {
2137
2183
  const stop = this.overBudget();
2138
2184
  if (stop) return stop;
2139
2185
  const id = `worker-${++this.workerSeq}`;
2140
- this.launchWorker(id, task);
2186
+ // The worker gets the project's notes with its brief: what earlier crews
2187
+ // learned is exactly what a fresh session lacks.
2188
+ this.launchWorker(id, this.memoryForWorkers ? `${task}\n${this.memoryForWorkers}` : task);
2141
2189
  return `[${id} started] status: running. It works in the background — use check_workers ` +
2142
2190
  `to watch it, and wait_for_worker when you need its result.`;
2143
2191
  }
@@ -2279,7 +2327,6 @@ export class MissionRun {
2279
2327
  this.pendingQuestions.set(id, resolve);
2280
2328
  this.armAsk(id, (afterMs) => {
2281
2329
  if (!this.pendingQuestions.delete(id)) return;
2282
- this.askMeta.delete(id);
2283
2330
  this.askMeta.delete(id);
2284
2331
  timedOut = true;
2285
2332
  this.emit('question_timeout', { id, afterMs });
@@ -2317,9 +2364,24 @@ export class MissionRun {
2317
2364
  'running while they may want to look, and put the URL in your report.' }] };
2318
2365
  },
2319
2366
  );
2367
+ const remember = tool(
2368
+ 'remember',
2369
+ 'Rewrite the project memory (.foreman/MEMORY.md): the whole page as it should read now, ' +
2370
+ 'for the next crew. Facts about this project — how to run and test it, ports and paths, ' +
2371
+ 'conventions and why, traps — one line each, a page at most. Replace stale lines; do not ' +
2372
+ 'append forever. Never a secret; anything that looks like one is stripped.',
2373
+ { text: z.string().max(20_000).describe('The complete memory file content, Markdown') },
2374
+ async ({ text: body }) => {
2375
+ const r = await writeMemory(this.meta.folder, body);
2376
+ this.emit('memory_updated', { bytes: r.bytes, redacted: r.redacted, trimmed: r.trimmed,
2377
+ text: `Project memory rewritten (${r.bytes} bytes${r.redacted ? `, ${r.redacted} secret-looking value${r.redacted === 1 ? '' : 's'} stripped` : ''}${r.trimmed ? ', trimmed to the cap' : ''}).` });
2378
+ return { content: [{ type: 'text' as const, text:
2379
+ `Memory written (${r.bytes} bytes).${r.redacted ? ` ${r.redacted} value(s) that looked like secrets were replaced with [redacted]; do not put credentials in memory.` : ''}${r.trimmed ? ' It was longer than the cap and has been cut at the end — prune it to a page.' : ''}` }] };
2380
+ },
2381
+ );
2320
2382
  return createSdkMcpServer({
2321
2383
  name: 'foreman',
2322
- tools: [spawnWorker, checkWorkers, waitForWorker, messageWorker, askHuman, exposeService],
2384
+ tools: [spawnWorker, checkWorkers, waitForWorker, messageWorker, askHuman, exposeService, remember],
2323
2385
  });
2324
2386
  }
2325
2387
  }
package/src/planner.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  } from '@anthropic-ai/claude-agent-sdk';
30
30
  import type { AgentEnv } from './provider.js';
31
31
  import type { MissionProposal } from './types.js';
32
+ import { memorySection, readMemory } from './memory.js';
32
33
  import {
33
34
  armAskTimeout, formatAnswers, normaliseQuestions,
34
35
  type AskAnswers, type AskQuestion, type PendingAsk,
@@ -172,6 +173,8 @@ export interface PlannerModel {
172
173
  providerLabel: string;
173
174
  costBasis: 'priced' | 'free' | 'unpriced';
174
175
  note?: string;
176
+ /** Its track record here, from the run ledger: "here: director 4/5 done (~$0.78, ~18 min)". */
177
+ record?: string;
175
178
  }
176
179
 
177
180
  /**
@@ -205,7 +208,7 @@ export function pickKnownModel(
205
208
  export function modelsSection(models: PlannerModel[] | undefined): string {
206
209
  if (!models?.length) return '';
207
210
  const lines = models.map((m) =>
208
- ` - ${m.id} — ${m.providerLabel} · ${m.costBasis}${m.note ? ` · ${m.note}` : ''}`);
211
+ ` - ${m.id} — ${m.providerLabel} · ${m.costBasis}${m.note ? ` · ${m.note}` : ''}${m.record ? ` · ${m.record}` : ''}`);
209
212
  return `\nMODELS AVAILABLE ON THIS MACHINE (use these exact ids in propose_mission):\n${lines.join('\n')}\n`;
210
213
  }
211
214
 
@@ -214,6 +217,8 @@ export interface PlanningTurn {
214
217
  projectId: string;
215
218
  /** What the machine can run, so recommendations are real ids, not guesses. */
216
219
  models?: PlannerModel[];
220
+ /** What missions have cost here and across the fleet, from the ledger; '' when too little history. */
221
+ anchor?: string;
217
222
  /** Session to resume; absent starts a fresh conversation. */
218
223
  sessionId?: string;
219
224
  folder: string;
@@ -407,7 +412,7 @@ export async function runPlanningTurn(turn: PlanningTurn): Promise<PlanningResul
407
412
  // thinking about the proposal, not discover it by asking.
408
413
  systemPrompt: {
409
414
  type: 'preset', preset: 'claude_code',
410
- append: PLANNER_CHARTER + modelsSection(turn.models),
415
+ append: PLANNER_CHARTER + modelsSection(turn.models) + (turn.anchor ?? '') + memorySection((await readMemory(turn.folder)).text, 'planner'),
411
416
  },
412
417
  mcpServers: { foreman: createSdkMcpServer({ name: 'foreman', tools: [proposeMission, askUser] }) },
413
418
  canUseTool,
package/src/preflight.ts CHANGED
@@ -21,6 +21,7 @@ import { defaultInstance, describeInstance, effectiveConfigDir } from './instanc
21
21
  import { discoverOllama, ollamaHost } from './ollama.js';
22
22
  import { serveHint, tailnetUrl, type Tailnet } from './tailscale.js';
23
23
  import { codexHome, codexModels, readCodexAuth } from './codex.js';
24
+ import { browserCheck } from './browser.js';
24
25
  import { createRequire } from 'node:module';
25
26
  import { fileURLToPath } from 'node:url';
26
27
 
@@ -116,12 +117,17 @@ async function checkAuth(): Promise<Check> {
116
117
  const name = 'Credentials';
117
118
  const { mode, source, account } = await detectAuth();
118
119
 
120
+ // Missing credentials are a warning, not a refusal to start. The dashboard
121
+ // is where a first-time user reads what to do and where a project gets an
122
+ // API key or an endpoint of its own; a server that will not come up until
123
+ // something is logged in leaves them with a terminal line and no next step.
124
+ // Missions cannot start meanwhile — dispatch checks the provider itself.
119
125
  if (mode === 'none') {
120
126
  return {
121
127
  name,
122
- status: 'error',
123
- detail: 'none found — no missions can run',
124
- fix: 'Log in with Claude Code, or: export ANTHROPIC_API_KEY=sk-ant-...',
128
+ status: 'warn',
129
+ detail: 'none found — missions cannot start until a provider is set up',
130
+ fix: 'Sign in with Claude Code on this machine (claude, then /login) and restart · or export ANTHROPIC_API_KEY · or give a project an API key or endpoint in Settings → Provider',
125
131
  };
126
132
  }
127
133
 
@@ -142,8 +148,12 @@ async function checkAuth(): Promise<Check> {
142
148
  return { name, status: 'ok', detail: `${MODE_LABEL[mode]} — ${who}` };
143
149
  }
144
150
 
145
- function checkInstance(): Check {
146
- return { name: 'Claude Code', status: 'ok', detail: describeInstance(defaultInstance()) };
151
+ async function checkInstance(): Promise<Check> {
152
+ const detail = describeInstance(defaultInstance());
153
+ // A tick beside an install nobody is signed into contradicts the line
154
+ // above it; say so here too, and leave the fix to the Credentials row.
155
+ if ((await detectAuth()).mode === 'none') return { name: 'Claude Code', status: 'warn', detail: `${detail} · not signed in` };
156
+ return { name: 'Claude Code', status: 'ok', detail };
147
157
  }
148
158
 
149
159
  /**
@@ -204,7 +214,7 @@ async function checkCodex(): Promise<Check | null> {
204
214
  };
205
215
  }
206
216
 
207
- async function checkPort(port: number): Promise<Check> {
217
+ async function checkPort(port: number, envVar: 'PORT' | 'FOREMAN_SERVICES_PORT' = 'PORT'): Promise<Check> {
208
218
  const name = `Port ${port}`;
209
219
  const inUse = await new Promise<boolean>((resolve) => {
210
220
  const probe = net.createServer();
@@ -213,12 +223,17 @@ async function checkPort(port: number): Promise<Check> {
213
223
  probe.listen(port, '127.0.0.1');
214
224
  });
215
225
 
226
+ // The main port taken is fatal: nothing can be served. The services port
227
+ // taken is a fact to report — the dashboard still works, and only the
228
+ // crew's exposed previews are off until the port is freed or moved.
229
+ // PORT + 1 is a guess, and a second Foreman (a dev server beside the
230
+ // installed one) is the likeliest thing sitting on it.
216
231
  return inUse
217
232
  ? {
218
233
  name,
219
- status: 'error',
220
- detail: 'already in use',
221
- fix: `Another Foreman may be running. Stop it, or: PORT=${port + 1} npm start`,
234
+ status: envVar === 'PORT' ? 'error' : 'warn',
235
+ detail: envVar === 'PORT' ? 'already in use' : 'already in use — exposed dev servers will be unavailable',
236
+ fix: `Another Foreman may be running. Stop it, or: ${envVar}=${port + 1} npm start`,
222
237
  }
223
238
  : { name, status: 'ok', detail: 'free' };
224
239
  }
@@ -247,40 +262,8 @@ async function checkHome(root: string): Promise<Check> {
247
262
  * `npx playwright install chromium`).
248
263
  */
249
264
  async function checkBrowser(): Promise<Check> {
250
- const name = 'Browser';
251
- const want = (process.env.FOREMAN_BROWSER || 'chrome').toLowerCase();
252
- const candidates: Record<string, string[]> = {
253
- chrome: process.platform === 'darwin'
254
- ? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', path.join(os.homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome')]
255
- : process.platform === 'win32'
256
- ? [
257
- path.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
258
- path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
259
- path.join(process.env.LOCALAPPDATA ?? '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
260
- ]
261
- : ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/opt/google/chrome/chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'],
262
- msedge: process.platform === 'darwin' ? ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge']
263
- : process.platform === 'win32' ? [path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe')]
264
- : ['/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable'],
265
- firefox: process.platform === 'darwin' ? ['/Applications/Firefox.app/Contents/MacOS/firefox'] : ['/usr/bin/firefox'],
266
- };
267
- let found: string | null = null;
268
- if (want === 'chromium') {
269
- // Playwright's own build, wherever the MCP's playwright-core says it lives.
270
- try {
271
- const req = createRequire(fileURLToPath(new URL('../node_modules/@playwright/mcp/cli.js', import.meta.url)));
272
- const { chromium } = req('playwright-core') as { chromium: { executablePath(): string } };
273
- const p = chromium.executablePath();
274
- if (await exists(p)) found = p;
275
- } catch { /* no playwright-core to ask */ }
276
- return found
277
- ? { name, status: 'ok', detail: `Playwright Chromium — ${found}` }
278
- : { name, status: 'warn', detail: 'FOREMAN_BROWSER=chromium but Playwright Chromium is not installed; browser missions will fail', fix: 'npx playwright install chromium' };
279
- }
280
- for (const p of candidates[want] ?? []) if (await exists(p)) { found = p; break; }
281
- return found
282
- ? { name, status: 'ok', detail: `${want === 'chrome' ? 'Google Chrome' : want} — ${found}` }
283
- : { name, status: 'warn', detail: `no ${want === 'chrome' ? 'Google Chrome' : want} found; missions with browser on will fail`, fix: 'Install Google Chrome, or: npx playwright install chromium && FOREMAN_BROWSER=chromium foreman' };
265
+ // One detection for the row and for the crew: see src/browser.ts.
266
+ return { name: 'Browser', ...(await browserCheck()) };
284
267
  }
285
268
 
286
269
  /** Where the phone can reach this. Says so plainly either way — the answer decides which links work. */
@@ -309,6 +292,8 @@ async function checkUi(distDir: string): Promise<Check> {
309
292
  */
310
293
  export async function preflight(opts: {
311
294
  port: number;
295
+ /** The second listener, for the crew's exposed dev servers — see services.ts. */
296
+ servicesPort: number;
312
297
  foremanHome: string;
313
298
  distDir: string;
314
299
  /** Detected by the server before preflight; null when not on a tailnet. */
@@ -321,6 +306,7 @@ export async function preflight(opts: {
321
306
  checkOllama(),
322
307
  checkCodex(),
323
308
  checkPort(opts.port),
309
+ checkPort(opts.servicesPort, 'FOREMAN_SERVICES_PORT'),
324
310
  checkBrowser(),
325
311
  checkHome(opts.foremanHome),
326
312
  checkUi(opts.distDir),
@@ -0,0 +1,55 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { reconcileRole } from './role-provider.js';
4
+
5
+ const models = [
6
+ { id: 'fable' }, { id: 'opus' }, { id: 'sonnet' }, { id: 'haiku' },
7
+ { id: 'gemma4:12b', providerId: 'ollama-local' },
8
+ { id: 'gpt-5.5', providerId: 'codex-local' },
9
+ { id: 'kimi-k3', providerId: 'prov-abc' },
10
+ ];
11
+
12
+ test('reconcileRole: a consistent pairing passes through untouched', () => {
13
+ assert.deepEqual(reconcileRole('director', 'fable', undefined, models), { providerId: undefined });
14
+ assert.deepEqual(reconcileRole('workers', 'gpt-5.5', 'codex-local', models), { providerId: 'codex-local' });
15
+ assert.deepEqual(reconcileRole('workers', 'gemma4:12b', 'ollama-local', models), { providerId: 'ollama-local' });
16
+ });
17
+
18
+ test('reconcileRole: an Anthropic alias on the Codex provider is corrected — the bug this exists for', () => {
19
+ const r = reconcileRole('director', 'fable', 'codex-local', models);
20
+ assert.equal(r.providerId, undefined);
21
+ assert.match(r.note ?? '', /fable is served by the project's provider, not codex-local/);
22
+ });
23
+
24
+ test('reconcileRole: a listed model moves to whichever provider lists it', () => {
25
+ assert.equal(reconcileRole('workers', 'gemma4:12b', undefined, models).providerId, 'ollama-local');
26
+ assert.equal(reconcileRole('workers', 'gpt-5.5', 'ollama-local', models).providerId, 'codex-local');
27
+ assert.equal(reconcileRole('director', 'kimi-k3', 'codex-local', models).providerId, 'prov-abc');
28
+ assert.equal(reconcileRole('director', 'FABLE', 'codex-local', models).providerId, undefined);
29
+ });
30
+
31
+ test('reconcileRole: a provider pin without a model is stale and dropped', () => {
32
+ const r = reconcileRole('director', undefined, 'codex-local', models);
33
+ assert.equal(r.providerId, undefined);
34
+ assert.match(r.note ?? '', /no model was chosen/);
35
+ assert.deepEqual(reconcileRole('director', undefined, undefined, models), { providerId: undefined });
36
+ assert.deepEqual(reconcileRole('director', '', ' ', models), { providerId: undefined });
37
+ });
38
+
39
+ test('reconcileRole: an unlisted model keeps its provider, unless it is a Claude id on a closed provider', () => {
40
+ // A custom endpoint's model the discovery call missed: trust the pairing.
41
+ assert.deepEqual(reconcileRole('workers', 'mystery-9b', 'prov-abc', models), { providerId: 'prov-abc' });
42
+ assert.deepEqual(reconcileRole('workers', 'mystery-9b', undefined, models), { providerId: undefined });
43
+ // A full claude-* id nobody listed still cannot go through Codex or Ollama.
44
+ const r = reconcileRole('director', 'claude-opus-4-1', 'codex-local', models);
45
+ assert.equal(r.providerId, undefined);
46
+ assert.match(r.note ?? '', /Anthropic model/);
47
+ assert.equal(reconcileRole('director', 'claude-opus-4-1', 'ollama-local', models).providerId, undefined);
48
+ // On a custom endpoint a claude-* id may be a real proxy route; left alone.
49
+ assert.equal(reconcileRole('director', 'claude-opus-4-1', 'prov-abc', models).providerId, 'prov-abc');
50
+ });
51
+
52
+ test('reconcileRole: works with an empty model list (discovery failed) by the closed-provider rule alone', () => {
53
+ assert.equal(reconcileRole('director', 'fable', 'codex-local', []).providerId, undefined);
54
+ assert.equal(reconcileRole('director', 'gpt-5.5', 'codex-local', []).providerId, 'codex-local');
55
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * A role's provider, checked against its model at dispatch.
3
+ *
4
+ * A run carries, per role, a model and the provider that serves it. The two
5
+ * are picked together in every picker, but they travel separately — through
6
+ * settings files, drafts, older builds and resumes — and once they drift a
7
+ * mission fails on its first turn with an upstream 400 nobody chose: the
8
+ * Claude alias `fable` sent through the Codex gateway, say, which then
9
+ * answers that the model "is not supported when using Codex". Nothing was
10
+ * checking that the provider actually serves the model.
11
+ *
12
+ * This is that check. The model list is the authority: a model the machine
13
+ * offers is served by whichever provider lists it, and a role is corrected
14
+ * to that provider whatever it arrived with. A model the list does not know
15
+ * is left alone, except for the one pairing that can never work: an
16
+ * Anthropic model on a provider that only serves its own catalogue.
17
+ */
18
+ import { ANTHROPIC_ALIASES } from './anthropic-models.js';
19
+
20
+ export interface KnownModel {
21
+ id: string;
22
+ /** Absent means the project's own provider (Anthropic by default). */
23
+ providerId?: string;
24
+ }
25
+
26
+ /** Providers that serve exactly the models they list and nothing else. */
27
+ const CLOSED_PROVIDERS = new Set(['codex-local', 'ollama-local']);
28
+
29
+ export interface Reconciled {
30
+ providerId?: string;
31
+ /** Set when the pairing had to change; one sentence, for the transcript. */
32
+ note?: string;
33
+ }
34
+
35
+ const isAnthropic = (model: string) => ANTHROPIC_ALIASES.has(model) || /^claude-/i.test(model);
36
+
37
+ /**
38
+ * The provider a role should run on, given what it was handed and what the
39
+ * machine offers. Returns the same provider id and no note when nothing is
40
+ * wrong, which is the common case.
41
+ */
42
+ export function reconcileRole(
43
+ role: 'director' | 'workers', model: string | undefined, providerId: string | undefined, models: KnownModel[],
44
+ ): Reconciled {
45
+ const given = providerId?.trim() || undefined;
46
+ // No model means "inherit the project's default", which lives on the
47
+ // project's provider. A provider id left behind without a model is stale.
48
+ if (!model) {
49
+ return given ? { providerId: undefined, note: `${role}: no model was chosen, so the ${given} provider pin was dropped.` } : { providerId: undefined };
50
+ }
51
+ const want = model.trim().toLowerCase();
52
+ const known = models.find((m) => m.id.toLowerCase() === want);
53
+ if (known) {
54
+ const served = known.providerId || undefined;
55
+ if (served === given) return { providerId: given };
56
+ return {
57
+ providerId: served,
58
+ note: `${role}: ${model} is served by ${served ?? 'the project\'s provider'}, not ${given ?? 'the project\'s provider'}; corrected.`,
59
+ };
60
+ }
61
+ if (given && CLOSED_PROVIDERS.has(given) && isAnthropic(model)) {
62
+ return { providerId: undefined, note: `${role}: ${model} is an Anthropic model and ${given} cannot serve it; running it on the project's provider instead.` };
63
+ }
64
+ return { providerId: given };
65
+ }