@amenophis1er/foreman 0.1.14 → 0.1.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amenophis1er/foreman",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
5
5
  "keywords": [
6
6
  "claude",
@@ -4,7 +4,8 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { execFileSync } from 'node:child_process';
6
6
  import { mkdtemp, writeFile } from 'node:fs/promises';
7
- import { closeMissionBranch, ensureMissionBranch, gitInfo, missionBranchName, startMissionBranch, renameMissionBranch } from './gitwork.js';
7
+ import { closeMissionBranch, defaultBranch, ensureMissionBranch, gitInfo, missionBranchName, remoteHasBranch, resolvePrBase, startMissionBranch, renameMissionBranch, dirtyPaths,
8
+ } from './gitwork.js';
8
9
 
9
10
  const sh = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null' } }).toString();
10
11
 
@@ -108,3 +109,40 @@ test('renameMissionBranch takes the run title once there is one, and leaves a br
108
109
  sh(dir, 'checkout', '-q', 'main');
109
110
  assert.equal(await renameMissionBranch(dir, renamed!, 'Another title', '1788713434983-226123af'), null);
110
111
  });
112
+
113
+ test('dirtyPaths names the uncommitted work a mission branch would carry', async () => {
114
+ const dir = await repo();
115
+ assert.deepEqual(await dirtyPaths(dir), [], 'a clean checkout carries nothing');
116
+
117
+ await writeFile(path.join(dir, 'README.md'), 'hello, edited\n');
118
+ await writeFile(path.join(dir, 'scratch.txt'), 'untracked\n');
119
+ const dirty = await dirtyPaths(dir);
120
+ assert.deepEqual(dirty.sort(), ['README.md', 'scratch.txt'], 'tracked edits and untracked files both count');
121
+
122
+ // The cap is for a message, not for the truth of it.
123
+ assert.equal((await dirtyPaths(dir, 1)).length, 1);
124
+ // Not a repository at all: nothing to report, and no throw.
125
+ assert.deepEqual(await dirtyPaths(os.tmpdir()), []);
126
+ });
127
+
128
+ test('a pull request targets the default branch when the mission was branched from a local-only branch', async () => {
129
+ const dir = await repo();
130
+ // A bare origin, the way a real clone has one.
131
+ const origin = await mkdtemp(path.join(os.tmpdir(), 'gitwork-origin-'));
132
+ execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { stdio: 'pipe' });
133
+ sh(dir, 'remote', 'add', 'origin', origin);
134
+ sh(dir, 'push', '-q', '-u', 'origin', 'main');
135
+
136
+ assert.equal(await defaultBranch(dir), 'main');
137
+ assert.equal(await remoteHasBranch(dir, 'main'), true);
138
+ assert.equal(await remoteHasBranch(dir, 'foreman/earlier-1234'), false);
139
+
140
+ // Branched from main: the base is real and is left alone.
141
+ assert.deepEqual(await resolvePrBase(dir, 'main'), { base: 'main', fellBack: false });
142
+
143
+ // The case from the field: the checkout was left on the previous mission's
144
+ // branch, so this mission recorded that as its base and it exists nowhere
145
+ // but here. gh would fail on it; the default branch is the honest target.
146
+ sh(dir, 'checkout', '-q', '-b', 'foreman/earlier-1234');
147
+ assert.deepEqual(await resolvePrBase(dir, 'foreman/earlier-1234'), { base: 'main', fellBack: true });
148
+ });
package/src/gitwork.ts CHANGED
@@ -85,6 +85,22 @@ export function missionBranchName(mission: string, runId: string): string {
85
85
  * Uncommitted changes come along, as `checkout -b` always does. Resolves to
86
86
  * the record for the run, or to one sentence on why it could not.
87
87
  */
88
+ /**
89
+ * The paths with uncommitted changes, tracked or untracked, capped for a
90
+ * message. `checkout -b` carries all of them onto the mission's branch, and
91
+ * the closing commit sweeps whatever is still uncommitted into the mission's
92
+ * own commit — so this is what a human stands to have committed under a
93
+ * mission's name without noticing.
94
+ */
95
+ export async function dirtyPaths(folder: string, limit = 8): Promise<string[]> {
96
+ try {
97
+ const out = await git(['status', '--porcelain', '--untracked-files=normal'], folder);
98
+ return out.split('\n').map((l) => l.slice(3).trim()).filter(Boolean).slice(0, limit);
99
+ } catch {
100
+ return [];
101
+ }
102
+ }
103
+
88
104
  export async function startMissionBranch(folder: string, mission: string, runId: string): Promise<MissionGit | { error: string }> {
89
105
  const info = await gitInfo(folder);
90
106
  if (!info.repo) return { error: 'not a git repository' };
@@ -245,6 +261,56 @@ export function pullRequestState(folder: string, url: string): Promise<{ state:
245
261
  });
246
262
  }
247
263
 
264
+ /**
265
+ * The repository's default branch as origin sees it, from the remote HEAD git
266
+ * recorded at clone time, then from the usual names, and `main` as the last
267
+ * word. Local only: no network, so it works offline and cannot hang.
268
+ */
269
+ export async function defaultBranch(folder: string): Promise<string> {
270
+ try {
271
+ const ref = (await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], folder)).trim();
272
+ const name = ref.replace(/^origin\//, '');
273
+ if (name) return name;
274
+ } catch { /* no remote HEAD recorded */ }
275
+ for (const name of ['main', 'master']) {
276
+ try {
277
+ await git(['rev-parse', '--verify', `refs/remotes/origin/${name}`], folder);
278
+ return name;
279
+ } catch { /* not this one */ }
280
+ }
281
+ return 'main';
282
+ }
283
+
284
+ /** Does origin have this branch? Asks the remote, and says no if it cannot ask. */
285
+ export async function remoteHasBranch(folder: string, branch: string): Promise<boolean> {
286
+ try {
287
+ const out = await git(['ls-remote', '--heads', 'origin', branch], folder, 10_000);
288
+ return out.trim().length > 0;
289
+ } catch {
290
+ // Offline or unauthenticated: fall back to what the last fetch recorded.
291
+ try {
292
+ await git(['rev-parse', '--verify', `refs/remotes/origin/${branch}`], folder);
293
+ return true;
294
+ } catch { return false; }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * What a pull request from this mission should actually target.
300
+ *
301
+ * A finished mission leaves the checkout on its own branch, so the next
302
+ * mission is branched from *that* — and records it as its base. That base
303
+ * lives only on this machine, so `gh pr create --base foreman/…` fails and a
304
+ * compare URL built from it 404s. When the recorded base is not on the
305
+ * remote, the default branch is the honest target, and the caller says so
306
+ * rather than quietly retargeting the request.
307
+ */
308
+ export async function resolvePrBase(folder: string, recorded: string): Promise<{ base: string; fellBack: boolean }> {
309
+ if (recorded && await remoteHasBranch(folder, recorded)) return { base: recorded, fellBack: false };
310
+ const base = await defaultBranch(folder);
311
+ return { base, fellBack: base !== recorded };
312
+ }
313
+
248
314
  /** `gh pr create`, as the user. Resolves to the PR's URL, or to why not. */
249
315
  export function createPullRequest(folder: string, opts: { base: string; branch: string; title: string; body: string }): Promise<{ url?: string; error?: string }> {
250
316
  return new Promise((resolve) => {
package/src/notify.ts CHANGED
@@ -234,10 +234,14 @@ export function shape(env: Envelope, ctx: NotifyContext): Shaped | null {
234
234
  // --- money and completion -----------------------------------------
235
235
  case 'budget_alert':
236
236
  return { key: `budget:${env.runId}:${d.level}`, gate: 'budget',
237
- text: `${head(d.level === 'exceeded' ? 'Budget exceeded — run interrupted' : 'Budget cap reached')}${runLine}\n${esc(clip(d.text))}${foot}` };
237
+ text: `${head(d.level === 'exceeded' ? 'Budget exceeded — run interrupted'
238
+ : d.level === 'warn' ? 'Budget warning — winding down' : 'Budget cap reached')}${runLine}\n${esc(clip(d.text))}${foot}` };
238
239
  case 'budget_stop':
239
240
  return { key: `stop:${env.runId}`, gate: 'budget',
240
241
  text: `${head('Run winding down')}${runLine}\n${esc(clip(d.reason))}${foot}` };
242
+ case 'mission_done_at_cap':
243
+ return { key: `donecap:${env.runId}`, gate: 'done',
244
+ text: `${head('Done, at the cap')}${runLine}\n${esc(clip(d.text))}${foot}` };
241
245
  case 'mission_incomplete':
242
246
  return { key: `incomplete:${env.runId}`, gate: 'done',
243
247
  text: `${head('Not done')}${runLine}\n${esc(clip(d.text))}${foot}` };
@@ -9,7 +9,7 @@ import {
9
9
  stalledWorkerReport, workerStatusBlock,
10
10
  watchRepeats, watchSilence, REPEAT_EXEMPT, observeToolUse,
11
11
  DEFAULT_ASK_TIMEOUT_MS, armAskTimeout, unattendedAnswer, unattendedDenyMessage,
12
- tokenCapLabel, stopReasonOf,
12
+ tokenCapLabel, stopReasonOf, budgetWarnDue, doneAtCap,
13
13
  } from './orchestrator.js';
14
14
  import { makePolicy, type PendingPermission } from './policy.js';
15
15
  import type { AgentEnv } from './provider.js';
@@ -1256,3 +1256,27 @@ test('stopReasonOf names the cap behind a capReached sentence', () => {
1256
1256
  assert.equal(stopReasonOf('TIME CAP REACHED: 240 minutes.'), 'time');
1257
1257
  assert.equal(stopReasonOf('TOKEN CAP REACHED: 20.1M tokens.'), 'tokens');
1258
1258
  });
1259
+
1260
+ test('budgetWarnDue: once past the threshold, and only while still under the cap', () => {
1261
+ assert.equal(budgetWarnDue(7.9, 10, 80), false);
1262
+ assert.equal(budgetWarnDue(8, 10, 80), true);
1263
+ assert.equal(budgetWarnDue(9.99, 10, 80), true);
1264
+ // At and past the cap the wind-down order speaks instead.
1265
+ assert.equal(budgetWarnDue(10, 10, 80), false);
1266
+ assert.equal(budgetWarnDue(12, 10, 80), false);
1267
+ // No threshold, a nonsensical one, or no cap: nothing to warn about.
1268
+ assert.equal(budgetWarnDue(9, 10, undefined), false);
1269
+ assert.equal(budgetWarnDue(9, 10, 0), false);
1270
+ assert.equal(budgetWarnDue(9, 10, 100), false);
1271
+ assert.equal(budgetWarnDue(9, 0, 80), false);
1272
+ });
1273
+
1274
+ test('doneAtCap: budget-stopped with every criterion ticked is done, and nothing else is', () => {
1275
+ const budget = { budgetStopped: true, wasInterrupted: false, usageLimited: false };
1276
+ assert.equal(doneAtCap(budget, []), true);
1277
+ assert.equal(doneAtCap(budget, ['screenshots saved']), false, 'an unticked box is not done');
1278
+ assert.equal(doneAtCap(budget, null), false, 'no DONE WHEN section says nothing either way');
1279
+ assert.equal(doneAtCap({ ...budget, wasInterrupted: true }, []), false, 'a human stop is not a finish');
1280
+ assert.equal(doneAtCap({ ...budget, usageLimited: true }, []), false, 'a usage limit is not a finish');
1281
+ assert.equal(doneAtCap({ ...budget, budgetStopped: false }, []), false);
1282
+ });
@@ -266,6 +266,31 @@ const DEFAULT_MAX_TURNS = 150;
266
266
  export const DEFAULT_MAX_TOKENS = 20_000_000;
267
267
 
268
268
  /** The cap behind a capReached() sentence, as the run record stores it. */
269
+ /**
270
+ * Is it time to tell the director to start winding down? True once spend
271
+ * crosses the warn threshold and while it is still under the cap — past the
272
+ * cap the wind-down order takes over, and a warning then would be noise.
273
+ */
274
+ export function budgetWarnDue(costUsd: number, budgetUsd: number, warnAt: number | undefined): boolean {
275
+ if (!Number.isFinite(warnAt as number) || (warnAt as number) <= 0 || (warnAt as number) >= 100) return false;
276
+ if (!(budgetUsd > 0)) return false;
277
+ return costUsd >= budgetUsd * ((warnAt as number) / 100) && costUsd < budgetUsd;
278
+ }
279
+
280
+ /**
281
+ * A run the budget stopped, whose own DONE WHEN criteria are all ticked, is
282
+ * done: it reached its criteria and then reached its limit, in that order.
283
+ * Only for the budget — a human interrupt or a usage limit says nothing
284
+ * about the work — and only when the doc actually had criteria to tick.
285
+ */
286
+ export function doneAtCap(
287
+ flags: { budgetStopped: boolean; wasInterrupted: boolean; usageLimited: boolean },
288
+ unmet: string[] | null,
289
+ ): boolean {
290
+ if (!flags.budgetStopped || flags.wasInterrupted || flags.usageLimited) return false;
291
+ return Array.isArray(unmet) && unmet.length === 0;
292
+ }
293
+
269
294
  export function stopReasonOf(cap: string): 'budget' | 'turns' | 'time' | 'tokens' {
270
295
  if (cap.startsWith('TURN')) return 'turns';
271
296
  if (cap.startsWith('TIME')) return 'time';
@@ -619,6 +644,14 @@ export type Emitter = (event: string, data: unknown) => void;
619
644
  /** Persists updated run metadata (fire-and-forget from the run's viewpoint). */
620
645
  export type MetaSink = (meta: RunMeta) => void;
621
646
 
647
+ /**
648
+ * Turns a worker gets before the SDK stops it. Raised from 60: five workers
649
+ * in four missions hit the cap mid-task on legitimate, brief-sized work
650
+ * (a hundred TypeScript errors in one package; a test-suite port), and each
651
+ * time the director paid to respawn one that re-read the same files.
652
+ */
653
+ const WORKER_MAX_TURNS = 100;
654
+
622
655
  export const DIRECTOR_CHARTER = `
623
656
  You are the FOREMAN DIRECTOR. You run a mission autonomously inside one folder by
624
657
  directing worker agents. Non-negotiable rules, in priority order:
@@ -695,6 +728,16 @@ directing worker agents. Non-negotiable rules, in priority order:
695
728
  (a dev server, a static preview), call mcp__foreman__expose_service with
696
729
  its port and put the URL it returns in your report — the human can open
697
730
  it from their phone. Keep that server up until the mission ends.
731
+ CLEAN UP WHAT YOU STARTED: before you finish, stop every server, watcher or
732
+ background process the crew started that you are not deliberately leaving
733
+ for the human, and name in your report any you left up and on which port.
734
+ A process that outlives the mission holds its port until somebody hunts it
735
+ down by hand.
736
+ SIZE THE TASK TO THE WORKER: a worker is stopped after ${WORKER_MAX_TURNS}
737
+ turns of its own. It gets one continuation to finish what it was doing, and
738
+ after that the task comes back to you half-done. So give a worker a task it
739
+ can finish in that many steps — split the big ones — rather than one brief
740
+ that has to be rescued twice.
698
741
  4. REPORT WHAT YOU SEE. Judge the work as a competent professional would, not
699
742
  only against the letter of the acceptance criteria. If you observe a defect
700
743
  the criteria did not name — tap targets too small to use, unreadable
@@ -748,13 +791,6 @@ const USAGE_LIMIT_RE = /out of usage credits|usage limit reached|upgrade to incr
748
791
  /** One worker's outcome, as runWorker hands it back. */
749
792
  interface WorkerOutcome { report: string; isError: boolean }
750
793
 
751
- /**
752
- * Turns a worker gets before the SDK stops it. Raised from 60: five workers
753
- * in four missions hit the cap mid-task on legitimate, brief-sized work
754
- * (a hundred TypeScript errors in one package; a test-suite port), and each
755
- * time the director paid to respawn one that re-read the same files.
756
- */
757
- const WORKER_MAX_TURNS = 100;
758
794
  /** What a worker stopped by the cap is told when its session is picked back up. */
759
795
  const WORKER_CONTINUE_PROMPT =
760
796
  'You were stopped by the turn cap, not by a failure. Your session and your files are as you left them; ' +
@@ -889,7 +925,7 @@ export class MissionRun {
889
925
  * has no `expose_service` to offer.
890
926
  */
891
927
  private readonly host: {
892
- exposeService?: (runId: string, port: number, label: string) => Promise<{ ok: true; url: string; path: string } | { ok: false; reason: string }>;
928
+ exposeService?: (runId: string, port: number, label: string) => Promise<{ ok: true; url: string; path: string; pid?: number } | { ok: false; reason: string }>;
893
929
  /** The browser channel detected at dispatch (src/browser.ts); Chrome when the host says nothing. */
894
930
  browserChannel?: string;
895
931
  } = {},
@@ -1080,6 +1116,7 @@ export class MissionRun {
1080
1116
  // overrun quiet.
1081
1117
  this.budgetNoticeSent = false;
1082
1118
  this.budgetKillSent = false;
1119
+ this.budgetWarnSent = false;
1083
1120
  this.budgetStopped = false;
1084
1121
  }
1085
1122
  }
@@ -1332,6 +1369,23 @@ export class MissionRun {
1332
1369
  // the one lie a mission runner cannot afford. Downgrading to
1333
1370
  // 'interrupted' is also the useful answer: it is what makes the run
1334
1371
  // resumable rather than closed.
1372
+ // A mission whose own record says every criterion is verified is done,
1373
+ // even if the cap ended the turn it was writing its report in. Calling
1374
+ // that 'interrupted' told the fleet a finished mission had failed, and
1375
+ // invited a resume that spent more to rewrite a report already on disk.
1376
+ // The stop reason stays on the record, so nothing is hidden.
1377
+ if (this.meta.status === 'interrupted' && this.budgetStopped
1378
+ && !this.wasInterrupted && !this.usageLimited) {
1379
+ const unmet = await this.unmetCriteria();
1380
+ if (doneAtCap({ budgetStopped: this.budgetStopped, wasInterrupted: this.wasInterrupted, usageLimited: this.usageLimited }, unmet)) {
1381
+ this.meta.status = 'done';
1382
+ this.emit('mission_done_at_cap', {
1383
+ costUsd: this.meta.costUsd, budgetUsd: this.meta.budgetUsd, reason: this.meta.stopReason,
1384
+ text: 'Every DONE WHEN criterion was verified before the cap ended the run, so this mission is done. ' +
1385
+ 'It stopped at its limit rather than finishing under it — the report may be shorter than usual.',
1386
+ });
1387
+ }
1388
+ }
1335
1389
  if (this.meta.status === 'done') {
1336
1390
  const unmet = await this.unmetCriteria();
1337
1391
  if (unmet?.length) {
@@ -1482,6 +1536,7 @@ export class MissionRun {
1482
1536
 
1483
1537
  private budgetNoticeSent = false;
1484
1538
  private budgetKillSent = false;
1539
+ private budgetWarnSent = false;
1485
1540
 
1486
1541
  /**
1487
1542
  * Keeps an "always allow" grant out of `git status`.
@@ -1652,6 +1707,26 @@ export class MissionRun {
1652
1707
  void this.interrupt();
1653
1708
  return;
1654
1709
  }
1710
+ // Before the fence, a nudge. A director that only learns of the cap when
1711
+ // it hits it does its verification and its report inside the one turn it
1712
+ // has left — which is how two missions that had finished their work were
1713
+ // cut mid-report and read as failures. Warn while there is still room to
1714
+ // wind down deliberately.
1715
+ if (!this.budgetWarnSent && budgetWarnDue(costUsd, budgetUsd, this.meta.budgetWarnAt)) {
1716
+ this.budgetWarnSent = true;
1717
+ const pct = Math.round((costUsd / budgetUsd) * 100);
1718
+ this.emit('budget_alert', {
1719
+ level: 'warn', costUsd, budgetUsd,
1720
+ text: `${pct}% of the budget spent ($${costUsd.toFixed(2)} of $${budgetUsd.toFixed(2)}) — director told to start verifying.`,
1721
+ });
1722
+ this.directorInput?.push(
1723
+ '[BUDGET — automated notice]\n' +
1724
+ `You have spent $${costUsd.toFixed(2)} of the $${budgetUsd.toFixed(2)} cap (${pct}%). ` +
1725
+ 'Start winding down now: finish or stop the work in flight, do not begin anything you ' +
1726
+ 'cannot complete and verify within what is left, and get your verification and MISSION.md ' +
1727
+ 'up to date. At the cap you get one final turn, and at 125% the run is interrupted.',
1728
+ );
1729
+ }
1655
1730
  if (!this.budgetNoticeSent && costUsd >= budgetUsd) {
1656
1731
  this.budgetNoticeSent = true;
1657
1732
  this.emit('budget_alert', {
@@ -2393,7 +2468,7 @@ export class MissionRun {
2393
2468
  if (!fn) return { content: [{ type: 'text' as const, text: 'Exposing services is not available in this run.' }] };
2394
2469
  const r = await fn(this.meta.id, port, (label ?? '').trim() || `port ${port}`);
2395
2470
  if (!r.ok) return { content: [{ type: 'text' as const, text: `Not exposed: ${r.reason}` }] };
2396
- const entry = { port, label: (label ?? '').trim() || `port ${port}`, path: r.path, since: Date.now() };
2471
+ const entry = { port, label: (label ?? '').trim() || `port ${port}`, path: r.path, since: Date.now(), pid: r.pid };
2397
2472
  this.meta.services = [...(this.meta.services ?? []).filter((s) => s.port !== port), entry];
2398
2473
  this.saveMeta(this.meta);
2399
2474
  this.emit('service_exposed', { ...entry, url: r.url });
package/src/server.ts CHANGED
@@ -73,10 +73,10 @@ import { budgetAnchor, modelRecords, projectRecord, recordLine } from './track-r
73
73
  import { reconcileRole } from './role-provider.js';
74
74
  import { detectBrowser, installChromium } from './browser.js';
75
75
  import { frozenDeck, frozenMissionDoc, parkMissionDoc, restoreMissionDoc, snapshotRun } from './snapshot.js';
76
- import { closeMissionBranch, compareUrl, createPullRequest, ensureMissionBranch, ghReady, gitInfo, missionBranchName, prDraft, pullRequestState, pushBranch, renameMissionBranch, startMissionBranch, type GitInfo } from './gitwork.js';
76
+ import { closeMissionBranch, compareUrl, createPullRequest, dirtyPaths, ensureMissionBranch, ghReady, gitInfo, resolvePrBase, missionBranchName, prDraft, pullRequestState, pushBranch, renameMissionBranch, startMissionBranch, type GitInfo } from './gitwork.js';
77
77
  import { detectTailscale, tailnetUrl } from './tailscale.js';
78
78
  import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
79
- import { ServiceRegistry, portOpen, servicesHandler } from './services.js';
79
+ import { ServiceRegistry, listeningPid, portOpen, servicesHandler, stopService } from './services.js';
80
80
  import { HELP_TEXT, expandHome, parseCommand, projectsRoot, slug } from './notify/commands.js';
81
81
  import {
82
82
  DEFAULT_FLEET_MODEL, FLEET_CHAT_ID, PHONE_CONTEXT_MS, phoneRoute, runFleetTurn,
@@ -1578,8 +1578,11 @@ async function driveRun(
1578
1578
  exposeService: async (runId, port, label) => {
1579
1579
  if (servicesDown) return { ok: false, reason: servicesDown };
1580
1580
  if (!(await portOpen(port))) return { ok: false, reason: `nothing is listening on 127.0.0.1:${port} — start the server first` };
1581
- const svc = services.register(runId, port, label);
1582
- return { ok: true, url: `${await servicesBase()}${svc.path}`, path: svc.path };
1581
+ // Whoever holds the port right now is what the crew just started, and
1582
+ // the only process a later stop is allowed to touch.
1583
+ const pid = await listeningPid(port);
1584
+ const svc = services.register(runId, port, label, pid ?? undefined);
1585
+ return { ok: true, url: `${await servicesBase()}${svc.path}`, path: svc.path, pid: pid ?? undefined };
1583
1586
  },
1584
1587
  });
1585
1588
  activeByProject.set(projectId, run);
@@ -1648,6 +1651,8 @@ async function effectiveSettings(projectId: string): Promise<{
1648
1651
  directorProviderId?: string; workerProviderId?: string;
1649
1652
  /** In a repository, each mission runs on a branch of its own (default on). */
1650
1653
  gitBranchPerMission: boolean;
1654
+ /** Percent of the cap at which the director is told to start verifying (default 80). */
1655
+ budgetWarnAt: number;
1651
1656
  }> {
1652
1657
  const s = await store.readSettings()
1653
1658
  .catch(() => ({ global: {}, projects: {} as Record<string, object> }));
@@ -1669,6 +1674,10 @@ async function effectiveSettings(projectId: string): Promise<{
1669
1674
  directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
1670
1675
  workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
1671
1676
  gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
1677
+ budgetWarnAt: (() => {
1678
+ const raw = Number(p.budgetWarnAt ?? g.budgetWarnAt);
1679
+ return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw : 60;
1680
+ })(),
1672
1681
  };
1673
1682
  }
1674
1683
 
@@ -1708,6 +1717,7 @@ async function startRun(
1708
1717
  workerProviderId: roleProviders.worker ?? settings.workerProviderId,
1709
1718
  ownerPid: process.pid,
1710
1719
  browserTools: browserTools || undefined,
1720
+ budgetWarnAt: settings.budgetWarnAt,
1711
1721
  toolPolicy: settings.toolPolicy,
1712
1722
  autoAllowReadOnly: settings.autoAllowReadOnly,
1713
1723
  // Frozen at dispatch: a later change to the project or the server default
@@ -1737,6 +1747,7 @@ async function startRun(
1737
1747
  // touches anything — so the deck's baseline, taken at the director's first
1738
1748
  // turn, is the branch point, and the diff is exactly the mission.
1739
1749
  if (settings.gitBranchPerMission) {
1750
+ const carried = await dirtyPaths(folder);
1740
1751
  const g = await startMissionBranch(folder, mission, meta.id);
1741
1752
  const emit = makeEmitter(meta.id, projectId);
1742
1753
  if ('error' in g) {
@@ -1746,6 +1757,14 @@ async function startRun(
1746
1757
  await store.writeMeta(meta).catch(() => {});
1747
1758
  gitInfoCache.delete(folder);
1748
1759
  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.` });
1760
+ // Whoever started this may not have been at the keyboard to be asked.
1761
+ if (carried.length) {
1762
+ emit('git_note', {
1763
+ text: `The checkout had uncommitted changes when this mission started, and they came along to ${g.branch}: `
1764
+ + `${carried.slice(0, 5).join(', ')}${carried.length > 5 ? `, and ${carried.length - 5} more` : ''}. `
1765
+ + "They will be part of the mission's closing commit unless you take them off the branch first.",
1766
+ });
1767
+ }
1749
1768
  }
1750
1769
  }
1751
1770
  await driveRun(projectId, meta);
@@ -1946,6 +1965,8 @@ const server = http.createServer(async (req, res) => {
1946
1965
  const runResumeMatch = url.pathname.match(/^\/runs\/([^/]+)\/resume$/);
1947
1966
  const prMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr$/);
1948
1967
  const prStateMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr\/state$/);
1968
+ const runServicesMatch = url.pathname.match(/^\/runs\/([^/]+)\/services$/);
1969
+ const stopServiceMatch = url.pathname.match(/^\/runs\/([^/]+)\/services\/(\d{1,5})\/stop$/);
1949
1970
  const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
1950
1971
  const providerKeyMatch = url.pathname.match(/^\/providers\/([A-Za-z0-9_-]{1,64})\/key$/);
1951
1972
 
@@ -2231,7 +2252,7 @@ const server = http.createServer(async (req, res) => {
2231
2252
  } else if (req.method === 'POST' && url.pathname === '/run') {
2232
2253
  const {
2233
2254
  projectId, mission, budgetUsd, directorModel, workerModel, browserTools,
2234
- directorProviderId, workerProviderId,
2255
+ directorProviderId, workerProviderId, allowDirty,
2235
2256
  } = await readBody(req);
2236
2257
  if (typeof projectId !== 'string' || typeof mission !== 'string' || !mission.trim()) {
2237
2258
  return json(res, 400, { error: 'projectId and mission are required' });
@@ -2239,6 +2260,29 @@ const server = http.createServer(async (req, res) => {
2239
2260
  const project = await store.getProject(projectId);
2240
2261
  if (!project) return json(res, 404, { error: 'unknown project' });
2241
2262
  await mkdir(project.folder, { recursive: true });
2263
+ // A mission on its own branch takes the working tree with it: `checkout
2264
+ // -b` carries uncommitted work onto the mission branch, and the closing
2265
+ // commit sweeps whatever is left into the mission's own commit, under
2266
+ // the mission's name. So a dirty checkout is refused here rather than
2267
+ // quietly absorbed — with an override, because it is the human's tree
2268
+ // and their call. Only this route refuses: a mission started from the
2269
+ // phone has nobody standing at the keyboard to answer, and gets the
2270
+ // warning in its transcript instead.
2271
+ {
2272
+ const s = await effectiveSettings(projectId);
2273
+ if (s.gitBranchPerMission && allowDirty !== true) {
2274
+ const info = await gitInfo(project.folder);
2275
+ if (info.repo && info.dirty) {
2276
+ const files = await dirtyPaths(project.folder);
2277
+ return json(res, 409, {
2278
+ error: `This checkout has uncommitted changes on ${info.branch ?? 'HEAD'}. `
2279
+ + 'They would follow the mission onto its branch and be committed with its work. '
2280
+ + 'Commit or stash them first, or start anyway.',
2281
+ code: 'dirty-checkout', branch: info.branch ?? null, files,
2282
+ });
2283
+ }
2284
+ }
2285
+ }
2242
2286
  // Reservation is the last step before dispatch — no awaits in between.
2243
2287
  if (!reserveProject(projectId)) {
2244
2288
  return json(res, 409, { error: 'this project already has an active mission' });
@@ -2666,6 +2710,33 @@ const server = http.createServer(async (req, res) => {
2666
2710
  broadcastChat(FLEET_CHAT_ID, 'chat_cleared', {});
2667
2711
  json(res, 200, { ok: true });
2668
2712
 
2713
+ } else if (runServicesMatch && req.method === 'GET') {
2714
+ // What this run exposed, and what of it is still up. Asked after a run
2715
+ // ends as much as during it: a finished mission leaves no agent behind
2716
+ // to tidy, so the human needs somewhere to see the leftovers.
2717
+ const meta = await store.readMeta(runServicesMatch[1]).catch(() => null);
2718
+ if (!meta) return json(res, 404, { error: 'unknown run' });
2719
+ const list = await Promise.all((meta.services ?? []).map(async (s) => {
2720
+ const holder = await listeningPid(s.port);
2721
+ return {
2722
+ port: s.port, label: s.label, since: s.since, path: s.path,
2723
+ listening: holder !== null,
2724
+ // Only a process Foreman watched start is one it will offer to stop.
2725
+ ours: holder !== null && s.pid !== undefined && holder === s.pid,
2726
+ };
2727
+ }));
2728
+ json(res, 200, { runId: meta.id, status: meta.status, services: list });
2729
+
2730
+ } else if (stopServiceMatch && req.method === 'POST') {
2731
+ const meta = await store.readMeta(stopServiceMatch[1]).catch(() => null);
2732
+ if (!meta) return json(res, 404, { error: 'unknown run' });
2733
+ const port = Number(stopServiceMatch[2]);
2734
+ const svc = (meta.services ?? []).find((s) => s.port === port);
2735
+ if (!svc) return json(res, 404, { error: 'this run did not expose that port' });
2736
+ const r = await stopService(port, svc.pid);
2737
+ if (!r.ok) return json(res, 409, { error: r.reason });
2738
+ json(res, 200, { ok: true, how: r.how });
2739
+
2669
2740
  } else if (prStateMatch && req.method === 'GET') {
2670
2741
  // What became of the run's pull request. A final answer (merged,
2671
2742
  // closed) is written to the run so it is not asked again; open is.
@@ -2688,9 +2759,13 @@ const server = http.createServer(async (req, res) => {
2688
2759
  if (!info.remote) return json(res, 409, { error: 'the repository has no origin remote to push to' });
2689
2760
  const doc = await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null);
2690
2761
  const gh = await ghReady();
2762
+ // Where the request can actually go, which is not always where the
2763
+ // mission was branched from — see resolvePrBase.
2764
+ const target = await resolvePrBase(meta.folder, meta.git.base);
2691
2765
  json(res, 200, {
2692
- ...prDraft(meta, doc), branch: meta.git.branch, base: meta.git.base, commits: meta.git.commits ?? null,
2693
- remote: info.remote, compareUrl: compareUrl(info.remote, meta.git.base, meta.git.branch),
2766
+ ...prDraft(meta, doc), branch: meta.git.branch, base: target.base, commits: meta.git.commits ?? null,
2767
+ branchedFrom: meta.git.base, baseFellBack: target.fellBack,
2768
+ remote: info.remote, compareUrl: compareUrl(info.remote, target.base, meta.git.branch),
2694
2769
  gh, pr: meta.git.pr ?? null, onBranch: info.branch === meta.git.branch, dirty: Boolean(info.dirty),
2695
2770
  });
2696
2771
 
@@ -2718,18 +2793,24 @@ const server = http.createServer(async (req, res) => {
2718
2793
  let url: string | undefined;
2719
2794
  let method: 'gh' | 'compare' = 'compare';
2720
2795
  let note: string | undefined;
2796
+ const target = await resolvePrBase(meta.folder, meta.git.base);
2721
2797
  if (gh.present && gh.authed && /github\.com/.test(info.remote)) {
2722
- const r = await createPullRequest(meta.folder, { base: meta.git.base, branch: meta.git.branch, title: t, body: b });
2798
+ const r = await createPullRequest(meta.folder, { base: target.base, branch: meta.git.branch, title: t, body: b });
2723
2799
  if (r.url) { url = r.url; method = 'gh'; } else note = r.error;
2724
2800
  }
2725
- if (!url) url = compareUrl(info.remote, meta.git.base, meta.git.branch) ?? undefined;
2801
+ if (!url) url = compareUrl(info.remote, target.base, meta.git.branch) ?? undefined;
2726
2802
  meta.git = { ...meta.git, pr: method === 'gh' ? url : meta.git.pr };
2727
2803
  await store.writeMeta(meta).catch(() => {});
2728
2804
  gitInfoCache.delete(meta.folder);
2729
2805
  const text = method === 'gh'
2730
2806
  ? `Pushed ${meta.git.branch} and opened a pull request: ${url}`
2731
2807
  : `Pushed ${meta.git.branch}.${note ? ` ${note}.` : ''} Finish the pull request in the browser: ${url ?? 'open the repository'}`;
2732
- emit('pull_request', { branch: meta.git.branch, base: meta.git.base, url, method, text });
2808
+ emit('pull_request', {
2809
+ branch: meta.git.branch, base: target.base, url, method,
2810
+ text: target.fellBack
2811
+ ? `${text} (targeting ${target.base}: this mission was branched from ${meta.git.base}, which is not on the remote)`
2812
+ : text,
2813
+ });
2733
2814
  json(res, 200, { ok: true, url, method, pushed: true, note });
2734
2815
 
2735
2816
  } else if (req.method === 'POST' && url.pathname === '/steer') {
@@ -2951,7 +3032,7 @@ void closeOrphanedChatTurns().then((ids) => {
2951
3032
  // Services declared by earlier runs are still worth proxying if their
2952
3033
  // processes outlived the run; the registry is rebuilt from what was saved.
2953
3034
  void store.listRuns().then((runs) => {
2954
- for (const r of runs) for (const s of (r as { services?: Array<{ port: number; label: string }> }).services ?? []) services.register(r.id, s.port, s.label);
3035
+ for (const r of runs) for (const s of (r as { services?: Array<{ port: number; label: string; pid?: number }> }).services ?? []) services.register(r.id, s.port, s.label, s.pid);
2955
3036
  }).catch(() => {});
2956
3037
 
2957
3038
  if (BIND === 'all') {
@@ -1,7 +1,8 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import http from 'node:http';
4
- import { ServiceRegistry, parseServicePath, portOpen, proxyToService, servicePath, servicesHandler } from './services.js';
4
+ import { ServiceRegistry, listeningPid, parseServicePath, portOpen, proxyToService, servicePath, servicesHandler, stopService } from './services.js';
5
+ import { spawn } from 'node:child_process';
5
6
  import { requestAllowed } from './guard.js';
6
7
 
7
8
  test('service paths round-trip and reject junk', () => {
@@ -86,3 +87,47 @@ test('the services port serves only /svc/ — everything else is a 404', async (
86
87
  server.close();
87
88
  }
88
89
  });
90
+
91
+ /** A child process holding a port, the way a crew's dev server does. */
92
+ async function server(): Promise<{ pid: number; port: number; kill: () => void }> {
93
+ const child = spawn(process.execPath, ['-e',
94
+ "const s=require('http').createServer((_,r)=>r.end('ok'));s.listen(0,'127.0.0.1',()=>console.log(s.address().port));"],
95
+ { stdio: ['ignore', 'pipe', 'ignore'] });
96
+ const port = await new Promise<number>((resolve, reject) => {
97
+ child.stdout.once('data', (b) => resolve(Number(String(b).trim())));
98
+ child.once('error', reject);
99
+ setTimeout(() => reject(new Error('the test server never reported a port')), 5000);
100
+ });
101
+ return { pid: child.pid as number, port, kill: () => { try { child.kill('SIGKILL'); } catch { /* gone */ } } };
102
+ }
103
+
104
+ test('listeningPid names the process holding a port, and nobody for a free one', async () => {
105
+ const s = await server();
106
+ try {
107
+ assert.equal(await listeningPid(s.port), s.pid);
108
+ } finally { s.kill(); }
109
+ // Once it is gone the port is nobody's.
110
+ await new Promise((r) => setTimeout(r, 300));
111
+ assert.equal(await listeningPid(s.port), null);
112
+ });
113
+
114
+ test('stopService stops the recorded process, and refuses every other case', async () => {
115
+ const s = await server();
116
+ try {
117
+ assert.deepEqual(await stopService(s.port, undefined), {
118
+ ok: false, reason: 'Foreman did not record which process this was, so it will not kill anything.',
119
+ }, 'no recorded pid means no killing');
120
+
121
+ const wrong = await stopService(s.port, s.pid + 100000);
122
+ assert.equal(wrong.ok, false, 'a port held by someone else is left alone');
123
+ assert.match((wrong as { reason: string }).reason, /not the/);
124
+ assert.equal(await portOpen(s.port), true, 'and the refusal really did leave it running');
125
+
126
+ const stopped = await stopService(s.port, s.pid);
127
+ assert.equal(stopped.ok, true);
128
+ assert.equal(await portOpen(s.port), false);
129
+
130
+ const gone = await stopService(s.port, s.pid);
131
+ assert.equal(gone.ok, false, 'stopping twice is not an error worth pretending about');
132
+ } finally { s.kill(); }
133
+ });