@amenophis1er/foreman 0.1.15 → 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.15",
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) => {
@@ -644,6 +644,14 @@ export type Emitter = (event: string, data: unknown) => void;
644
644
  /** Persists updated run metadata (fire-and-forget from the run's viewpoint). */
645
645
  export type MetaSink = (meta: RunMeta) => void;
646
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
+
647
655
  export const DIRECTOR_CHARTER = `
648
656
  You are the FOREMAN DIRECTOR. You run a mission autonomously inside one folder by
649
657
  directing worker agents. Non-negotiable rules, in priority order:
@@ -720,6 +728,16 @@ directing worker agents. Non-negotiable rules, in priority order:
720
728
  (a dev server, a static preview), call mcp__foreman__expose_service with
721
729
  its port and put the URL it returns in your report — the human can open
722
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.
723
741
  4. REPORT WHAT YOU SEE. Judge the work as a competent professional would, not
724
742
  only against the letter of the acceptance criteria. If you observe a defect
725
743
  the criteria did not name — tap targets too small to use, unreadable
@@ -773,13 +791,6 @@ const USAGE_LIMIT_RE = /out of usage credits|usage limit reached|upgrade to incr
773
791
  /** One worker's outcome, as runWorker hands it back. */
774
792
  interface WorkerOutcome { report: string; isError: boolean }
775
793
 
776
- /**
777
- * Turns a worker gets before the SDK stops it. Raised from 60: five workers
778
- * in four missions hit the cap mid-task on legitimate, brief-sized work
779
- * (a hundred TypeScript errors in one package; a test-suite port), and each
780
- * time the director paid to respawn one that re-read the same files.
781
- */
782
- const WORKER_MAX_TURNS = 100;
783
794
  /** What a worker stopped by the cap is told when its session is picked back up. */
784
795
  const WORKER_CONTINUE_PROMPT =
785
796
  'You were stopped by the turn cap, not by a failure. Your session and your files are as you left them; ' +
@@ -914,7 +925,7 @@ export class MissionRun {
914
925
  * has no `expose_service` to offer.
915
926
  */
916
927
  private readonly host: {
917
- 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 }>;
918
929
  /** The browser channel detected at dispatch (src/browser.ts); Chrome when the host says nothing. */
919
930
  browserChannel?: string;
920
931
  } = {},
@@ -2457,7 +2468,7 @@ export class MissionRun {
2457
2468
  if (!fn) return { content: [{ type: 'text' as const, text: 'Exposing services is not available in this run.' }] };
2458
2469
  const r = await fn(this.meta.id, port, (label ?? '').trim() || `port ${port}`);
2459
2470
  if (!r.ok) return { content: [{ type: 'text' as const, text: `Not exposed: ${r.reason}` }] };
2460
- 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 };
2461
2472
  this.meta.services = [...(this.meta.services ?? []).filter((s) => s.port !== port), entry];
2462
2473
  this.saveMeta(this.meta);
2463
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);
@@ -1673,7 +1676,7 @@ async function effectiveSettings(projectId: string): Promise<{
1673
1676
  gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
1674
1677
  budgetWarnAt: (() => {
1675
1678
  const raw = Number(p.budgetWarnAt ?? g.budgetWarnAt);
1676
- return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw : 80;
1679
+ return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw : 60;
1677
1680
  })(),
1678
1681
  };
1679
1682
  }
@@ -1744,6 +1747,7 @@ async function startRun(
1744
1747
  // touches anything — so the deck's baseline, taken at the director's first
1745
1748
  // turn, is the branch point, and the diff is exactly the mission.
1746
1749
  if (settings.gitBranchPerMission) {
1750
+ const carried = await dirtyPaths(folder);
1747
1751
  const g = await startMissionBranch(folder, mission, meta.id);
1748
1752
  const emit = makeEmitter(meta.id, projectId);
1749
1753
  if ('error' in g) {
@@ -1753,6 +1757,14 @@ async function startRun(
1753
1757
  await store.writeMeta(meta).catch(() => {});
1754
1758
  gitInfoCache.delete(folder);
1755
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
+ }
1756
1768
  }
1757
1769
  }
1758
1770
  await driveRun(projectId, meta);
@@ -1953,6 +1965,8 @@ const server = http.createServer(async (req, res) => {
1953
1965
  const runResumeMatch = url.pathname.match(/^\/runs\/([^/]+)\/resume$/);
1954
1966
  const prMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr$/);
1955
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$/);
1956
1970
  const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
1957
1971
  const providerKeyMatch = url.pathname.match(/^\/providers\/([A-Za-z0-9_-]{1,64})\/key$/);
1958
1972
 
@@ -2238,7 +2252,7 @@ const server = http.createServer(async (req, res) => {
2238
2252
  } else if (req.method === 'POST' && url.pathname === '/run') {
2239
2253
  const {
2240
2254
  projectId, mission, budgetUsd, directorModel, workerModel, browserTools,
2241
- directorProviderId, workerProviderId,
2255
+ directorProviderId, workerProviderId, allowDirty,
2242
2256
  } = await readBody(req);
2243
2257
  if (typeof projectId !== 'string' || typeof mission !== 'string' || !mission.trim()) {
2244
2258
  return json(res, 400, { error: 'projectId and mission are required' });
@@ -2246,6 +2260,29 @@ const server = http.createServer(async (req, res) => {
2246
2260
  const project = await store.getProject(projectId);
2247
2261
  if (!project) return json(res, 404, { error: 'unknown project' });
2248
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
+ }
2249
2286
  // Reservation is the last step before dispatch — no awaits in between.
2250
2287
  if (!reserveProject(projectId)) {
2251
2288
  return json(res, 409, { error: 'this project already has an active mission' });
@@ -2673,6 +2710,33 @@ const server = http.createServer(async (req, res) => {
2673
2710
  broadcastChat(FLEET_CHAT_ID, 'chat_cleared', {});
2674
2711
  json(res, 200, { ok: true });
2675
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
+
2676
2740
  } else if (prStateMatch && req.method === 'GET') {
2677
2741
  // What became of the run's pull request. A final answer (merged,
2678
2742
  // closed) is written to the run so it is not asked again; open is.
@@ -2695,9 +2759,13 @@ const server = http.createServer(async (req, res) => {
2695
2759
  if (!info.remote) return json(res, 409, { error: 'the repository has no origin remote to push to' });
2696
2760
  const doc = await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null);
2697
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);
2698
2765
  json(res, 200, {
2699
- ...prDraft(meta, doc), branch: meta.git.branch, base: meta.git.base, commits: meta.git.commits ?? null,
2700
- 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),
2701
2769
  gh, pr: meta.git.pr ?? null, onBranch: info.branch === meta.git.branch, dirty: Boolean(info.dirty),
2702
2770
  });
2703
2771
 
@@ -2725,18 +2793,24 @@ const server = http.createServer(async (req, res) => {
2725
2793
  let url: string | undefined;
2726
2794
  let method: 'gh' | 'compare' = 'compare';
2727
2795
  let note: string | undefined;
2796
+ const target = await resolvePrBase(meta.folder, meta.git.base);
2728
2797
  if (gh.present && gh.authed && /github\.com/.test(info.remote)) {
2729
- 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 });
2730
2799
  if (r.url) { url = r.url; method = 'gh'; } else note = r.error;
2731
2800
  }
2732
- 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;
2733
2802
  meta.git = { ...meta.git, pr: method === 'gh' ? url : meta.git.pr };
2734
2803
  await store.writeMeta(meta).catch(() => {});
2735
2804
  gitInfoCache.delete(meta.folder);
2736
2805
  const text = method === 'gh'
2737
2806
  ? `Pushed ${meta.git.branch} and opened a pull request: ${url}`
2738
2807
  : `Pushed ${meta.git.branch}.${note ? ` ${note}.` : ''} Finish the pull request in the browser: ${url ?? 'open the repository'}`;
2739
- 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
+ });
2740
2814
  json(res, 200, { ok: true, url, method, pushed: true, note });
2741
2815
 
2742
2816
  } else if (req.method === 'POST' && url.pathname === '/steer') {
@@ -2958,7 +3032,7 @@ void closeOrphanedChatTurns().then((ids) => {
2958
3032
  // Services declared by earlier runs are still worth proxying if their
2959
3033
  // processes outlived the run; the registry is rebuilt from what was saved.
2960
3034
  void store.listRuns().then((runs) => {
2961
- 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);
2962
3036
  }).catch(() => {});
2963
3037
 
2964
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
+ });
package/src/services.ts CHANGED
@@ -16,11 +16,18 @@
16
16
  */
17
17
  import http from 'node:http';
18
18
  import net from 'node:net';
19
+ import { execFile } from 'node:child_process';
19
20
  import type { GuardVerdict } from './guard.js';
20
21
 
21
22
  export interface ExposedService {
22
23
  port: number;
23
24
  label: string;
25
+ /**
26
+ * The process listening on the port when the crew exposed it. Recorded so a
27
+ * stop later can prove it is still the same process — a port is a slot
28
+ * anyone can take, and Foreman must never kill something it did not start.
29
+ */
30
+ pid?: number;
24
31
  /** `/svc/<runId>/<port>/` — the path under the services origin, not Foreman's. */
25
32
  path: string;
26
33
  since: number;
@@ -53,14 +60,66 @@ export function portOpen(port: number, timeoutMs = 800): Promise<boolean> {
53
60
  });
54
61
  }
55
62
 
63
+ /**
64
+ * The pid of whatever is listening on a local port, via lsof. Null when
65
+ * nothing is, or when lsof is unavailable — in which case Foreman simply
66
+ * never offers to stop the service, which is the safe direction.
67
+ */
68
+ export async function listeningPid(port: number): Promise<number | null> {
69
+ return new Promise((resolve) => {
70
+ execFile('lsof', ['-nP', '-t', `-iTCP:${port}`, '-sTCP:LISTEN'], { timeout: 3000 }, (err, stdout) => {
71
+ if (err) return resolve(null);
72
+ const pid = Number(String(stdout).split('\n')[0]?.trim());
73
+ resolve(Number.isInteger(pid) && pid > 0 ? pid : null);
74
+ });
75
+ });
76
+ }
77
+
78
+ /** Is this process still alive? Signal 0 asks without sending anything. */
79
+ function alive(pid: number): boolean {
80
+ try { process.kill(pid, 0); return true; } catch { return false; }
81
+ }
82
+
83
+ export type StopResult =
84
+ | { ok: true; how: 'term' | 'kill' }
85
+ | { ok: false; reason: string };
86
+
87
+ /**
88
+ * Stops a service the crew started, and only that one. The pid recorded when
89
+ * it was exposed must still be the process holding the port: if something
90
+ * else took the port since, or the recorded process is gone, this refuses
91
+ * rather than guessing. SIGTERM first, so a dev server gets to clean up.
92
+ */
93
+ export async function stopService(port: number, expectedPid: number | undefined, graceMs = 4000): Promise<StopResult> {
94
+ if (!expectedPid) return { ok: false, reason: 'Foreman did not record which process this was, so it will not kill anything.' };
95
+ const holder = await listeningPid(port);
96
+ if (holder === null) return { ok: false, reason: `nothing is listening on 127.0.0.1:${port} any more` };
97
+ if (holder !== expectedPid) {
98
+ return { ok: false, reason: `port ${port} is held by process ${holder} now, not the ${expectedPid} this run started — leaving it alone` };
99
+ }
100
+ try { process.kill(expectedPid, 'SIGTERM'); } catch (err) {
101
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
102
+ }
103
+ const deadline = Date.now() + graceMs;
104
+ while (Date.now() < deadline) {
105
+ await new Promise((r) => setTimeout(r, 150));
106
+ if (!alive(expectedPid)) return { ok: true, how: 'term' };
107
+ }
108
+ try { process.kill(expectedPid, 'SIGKILL'); } catch { /* already gone */ }
109
+ await new Promise((r) => setTimeout(r, 200));
110
+ return alive(expectedPid)
111
+ ? { ok: false, reason: `process ${expectedPid} did not stop` }
112
+ : { ok: true, how: 'kill' };
113
+ }
114
+
56
115
  export class ServiceRegistry {
57
116
  private byRun = new Map<string, ExposedService[]>();
58
117
 
59
- register(runId: string, port: number, label: string): ExposedService {
118
+ register(runId: string, port: number, label: string, pid?: number): ExposedService {
60
119
  const list = this.byRun.get(runId) ?? [];
61
120
  const existing = list.find((s) => s.port === port);
62
- if (existing) { existing.label = label || existing.label; return existing; }
63
- const svc: ExposedService = { port, label: label || `port ${port}`, path: servicePath(runId, port), since: Date.now() };
121
+ if (existing) { existing.label = label || existing.label; if (pid) existing.pid = pid; return existing; }
122
+ const svc: ExposedService = { port, label: label || `port ${port}`, path: servicePath(runId, port), since: Date.now(), pid };
64
123
  this.byRun.set(runId, [...list, svc]);
65
124
  return svc;
66
125
  }
package/src/types.ts CHANGED
@@ -315,7 +315,7 @@ export interface RunMeta {
315
315
  */
316
316
  allowedRoots?: string[];
317
317
  /** Dev servers the crew exposed through Foreman's proxy (see services.ts). */
318
- services?: Array<{ port: number; label: string; path: string; since: number }>;
318
+ services?: Array<{ port: number; label: string; path: string; since: number; pid?: number }>;
319
319
  /**
320
320
  * How long an approval card or director question may wait for the human
321
321
  * before it is resolved with its unattended default (deny / "decide