@amenophis1er/foreman 0.1.13 → 0.1.14

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/README.md CHANGED
@@ -230,7 +230,7 @@ result.
230
230
  ```sh
231
231
  npm ci && npm run setup # dependencies, then the dashboard build
232
232
  npm start # serves http://localhost:4177
233
- npm test # 373 tests, node:test
233
+ npm test # 379 tests, node:test
234
234
  npm run typecheck # server and dashboard
235
235
  npm run dev # API + Vite together
236
236
  scripts/dev-restart.sh # restarts the server only when nothing would be lost
@@ -247,10 +247,12 @@ codex mcp add foreman -- foreman mcp
247
247
  agy mcp add foreman -- foreman mcp
248
248
  ```
249
249
 
250
- Tools: `fleet_status`, `list_runs`, `run_status` (with `wait_seconds`: one call
251
- that returns when the run changes), `run_transcript`, `mission_doc`,
250
+ Tools: `fleet_status`, `list_runs`, `run_status` (with `wait_seconds` and
251
+ `until`: one call that blocks until the run changes, finishes, or needs you),
252
+ `run_report` (a finished run in one call: the director's report, DONE WHEN,
253
+ changed files, branch and pull request), `run_transcript`, `mission_doc`,
252
254
  `project_memory`, `search_runs`, `doctor`, `link_project` (folder or Git URL),
253
- `start_mission`, `steer`. It talks to the running server at `FOREMAN_URL`
255
+ `start_mission`, `steer`. Start, wait until finished, read the report: three calls. It talks to the running server at `FOREMAN_URL`
254
256
  (default `http://localhost:4177`) and has no logic of its own.
255
257
 
256
258
  Deliberately absent: approving or denying, answering the director's questions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amenophis1er/foreman",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
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",
package/src/deck.ts CHANGED
@@ -847,7 +847,7 @@ function sendJson(res: ServerResponse, code: number, body: unknown): void {
847
847
  */
848
848
  export async function handleDeckRoute(
849
849
  req: IncomingMessage, res: ServerResponse, url: URL,
850
- lookup: (scope: 'runs' | 'projects', id: string) => Promise<{ folder: string } | null>,
850
+ lookup: (scope: 'runs' | 'projects', id: string) => Promise<{ folder: string; deck?: Deck } | null>,
851
851
  ): Promise<boolean> {
852
852
  // The same jail and the same viewer serve two scopes: a run (its deck,
853
853
  // relative to a baseline) and a project (its tree as it stands, no baseline).
@@ -863,7 +863,9 @@ export async function handleDeckRoute(
863
863
  if (!run) { sendJson(res, 404, { error: 'not found' }); return true; }
864
864
 
865
865
  if (what === 'deck') {
866
- sendJson(res, 200, await deckFor(run.folder, runId));
866
+ // A finished run's deck is the one frozen when it ended (snapshot.ts);
867
+ // the caller hands it over. A running run is diffed live.
868
+ sendJson(res, 200, run.deck ?? await deckFor(run.folder, runId));
867
869
  return true;
868
870
  }
869
871
  if (what === 'tree') {
@@ -4,7 +4,7 @@ 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 } from './gitwork.js';
7
+ import { closeMissionBranch, ensureMissionBranch, gitInfo, missionBranchName, startMissionBranch, renameMissionBranch } from './gitwork.js';
8
8
 
9
9
  const sh = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null' } }).toString();
10
10
 
@@ -93,3 +93,18 @@ test('prDraft: the run title, the brief, the boxes as the mission left them, and
93
93
  assert.match(d.body, /## Done when\n\n- \[x\] footer\.html exists\n- \[ \] linked from index/);
94
94
  assert.match(d.body, /branch `foreman\/add-a-footer-ab12` from `main` · spend \$0\.42/);
95
95
  });
96
+
97
+
98
+ test('renameMissionBranch takes the run title once there is one, and leaves a branch that moved on', async () => {
99
+ const dir = await repo();
100
+ const g = await startMissionBranch(dir, 'Repo: this folder is a git worktree. Do the thing.', '1788713434983-226123af');
101
+ assert.ok(!('error' in g));
102
+ assert.equal(g.branch, 'foreman/repo-this-folder-is-a-git-23af');
103
+ const renamed = await renameMissionBranch(dir, g.branch, 'Studio data sanitization and null handling', '1788713434983-226123af');
104
+ assert.equal(renamed, 'foreman/studio-data-sanitization-and-null-23af');
105
+ assert.equal(sh(dir, 'rev-parse', '--abbrev-ref', 'HEAD').trim(), renamed);
106
+ // Same name again: nothing to do. Not on the branch any more: left alone.
107
+ assert.equal(await renameMissionBranch(dir, renamed!, 'Studio data sanitization and null handling', '1788713434983-226123af'), null);
108
+ sh(dir, 'checkout', '-q', 'main');
109
+ assert.equal(await renameMissionBranch(dir, renamed!, 'Another title', '1788713434983-226123af'), null);
110
+ });
package/src/gitwork.ts CHANGED
@@ -70,7 +70,12 @@ export async function gitInfo(folder: string): Promise<GitInfo> {
70
70
  /** `foreman/<first words of the brief>-<id tail>`: readable in `git branch`, unique per run. */
71
71
  export function missionBranchName(mission: string, runId: string): string {
72
72
  const first = mission.split('\n').find((l) => l.trim())?.trim() ?? 'mission';
73
- const slug = first.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').split('-').filter(Boolean).slice(0, 6).join('-').slice(0, 40).replace(/-+$/, '') || 'mission';
73
+ // Whole words up to six and forty characters: a name cut mid-word
74
+ // ("null-handli") reads worse than a shorter one.
75
+ const words = first.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').split('-').filter(Boolean).slice(0, 6);
76
+ let slug = '';
77
+ for (const w of words) { const next = slug ? `${slug}-${w}` : w; if (next.length > 40) break; slug = next; }
78
+ slug = slug || words[0]?.slice(0, 40) || 'mission';
74
79
  const tail = runId.replace(/[^a-z0-9]/gi, '').slice(-4).toLowerCase();
75
80
  return `foreman/${slug}-${tail}`;
76
81
  }
@@ -92,6 +97,27 @@ export async function startMissionBranch(folder: string, mission: string, runId:
92
97
  return { branch, base: info.branch ?? 'HEAD', baseHead: info.head ?? null };
93
98
  }
94
99
 
100
+ /**
101
+ * The branch takes the run's title once there is one. Branches are created
102
+ * before the title exists (the title is a model call that lands seconds
103
+ * later), so they started from the brief's first words — and briefs that all
104
+ * open with the same boilerplate gave every run the same name. Renamed in
105
+ * place, only while nothing has been committed on it and it is still checked
106
+ * out. Returns the new name, or null when it was left as it was.
107
+ */
108
+ export async function renameMissionBranch(folder: string, from: string, title: string, runId: string): Promise<string | null> {
109
+ const to = missionBranchName(title, runId);
110
+ if (to === from) return null;
111
+ const info = await gitInfo(folder);
112
+ if (!info.repo || info.branch !== from) return null;
113
+ try {
114
+ await git(['branch', '-m', from, to], folder);
115
+ return to;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
95
121
  /**
96
122
  * Back on the mission's branch for a resume. Returns null when already or
97
123
  * now there, else why not — a dirty tree that would be clobbered, typically.
package/src/mcp.test.ts CHANGED
@@ -54,12 +54,12 @@ test('run_status: crew, DONE WHEN from the mission doc, needs, and no wait on a
54
54
  assert.match(r.text, /stopped at its budget cap/);
55
55
  assert.match(r.text, /DONE WHEN 1\/2\n open: docs updated/);
56
56
  assert.match(r.text, /worker-1 · done/);
57
- assert.match(r.text, /changed: no/);
57
+ assert.match(r.text, /^finished$/m);
58
58
  assert.ok(!calls.some((c) => c.path.startsWith('/events')), 'a finished run is never waited on');
59
59
  });
60
60
 
61
- test('run_status with a wait subscribes to /events and returns on the first event for that run', async () => {
62
- const sse = new ReadableStream<Uint8Array>({
61
+ test('run_status with a wait returns when the run\'s picture changes, not on a bare event', async () => {
62
+ const sse = () => new ReadableStream<Uint8Array>({
63
63
  start(c) {
64
64
  const enc = new TextEncoder();
65
65
  c.enqueue(enc.encode(': connected\n\n'));
@@ -67,18 +67,68 @@ test('run_status with a wait subscribes to /events and returns on the first even
67
67
  c.enqueue(enc.encode('event: worker_started\ndata: {"runId":"r1","projectId":"p1","data":{"id":"worker-2"}}\n\n'));
68
68
  },
69
69
  });
70
+ let rounds = 0;
70
71
  const { fetchImpl } = fakeServer({
71
- 'GET /runs': { runs: [run()] },
72
+ // First round: an event, but the same run — no change. Second: a new worker.
73
+ 'GET /runs': () => ({ runs: [run(rounds >= 2 ? { workers: [{ id: 'worker-1', status: 'done', costUsd: 0.4, task: 'write tests' }, { id: 'worker-2', status: 'running', costUsd: 0, task: 'more' }] } : {})] }),
72
74
  'GET /projects': { projects: [{ id: 'p1', name: 'app', folder: '/x/app', activeRun: run(), needs: [{ kind: 'perm', id: 'a1', runId: 'r1', text: 'director wants Bash — rm -rf dist' }] }] },
73
75
  'GET /missiondoc': { doc: '' },
74
- 'GET /events': new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } }),
76
+ 'GET /events': () => { rounds += 1; return new Response(sse(), { status: 200, headers: { 'content-type': 'text/event-stream' } }); },
75
77
  });
76
78
  const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_status').run({ runId: 'r1', wait_seconds: 5 });
77
79
  assert.match(r.text, /changed: yes/);
80
+ assert.ok(rounds >= 2, 'the first event changed nothing visible, so it kept waiting');
81
+ assert.match(r.text, /worker-2 · running/);
78
82
  assert.match(r.text, /NEEDS YOU \(1\) — only a human can answer/);
79
83
  assert.match(r.text, /\[perm\] director wants Bash/);
80
84
  });
81
85
 
86
+ test('run_status until=finished waits through events that are not the end, and stops at the end', async () => {
87
+ // Two rounds: a cost tick (not finished) then the run turns done.
88
+ let rounds = 0;
89
+ const sseOnce = () => new ReadableStream<Uint8Array>({ start(c) { c.enqueue(new TextEncoder().encode('event: cost\ndata: {"runId":"r1","projectId":"p1","data":{}}\n\n')); } });
90
+ const { fetchImpl, calls } = fakeServer({
91
+ 'GET /runs': () => ({ runs: [run({ status: rounds >= 2 ? 'done' : 'running' })] }),
92
+ 'GET /projects': { projects: [{ id: 'p1', name: 'app', folder: '/x/app', activeRun: null, needs: [] }] },
93
+ 'GET /missiondoc': { doc: '' },
94
+ 'GET /events': () => { rounds += 1; return new Response(sseOnce(), { status: 200 }); },
95
+ });
96
+ const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_status').run({ runId: 'r1', wait_seconds: 10, until: 'finished' });
97
+ assert.match(r.text, /r1 · done/);
98
+ assert.match(r.text, /^finished$/m);
99
+ assert.ok(calls.filter((c) => c.path.startsWith('/events')).length >= 2, 'kept waiting past the first event');
100
+ });
101
+
102
+ test('run_status until=needs_you returns when an approval appears', async () => {
103
+ let asked = false;
104
+ const { fetchImpl } = fakeServer({
105
+ 'GET /runs': { runs: [run()] },
106
+ 'GET /projects': () => ({ projects: [{ id: 'p1', name: 'app', folder: '/x/app', activeRun: run(), needs: asked ? [{ kind: 'perm', id: 'a1', runId: 'r1', text: 'director wants Bash' }] : [] }] }),
107
+ 'GET /missiondoc': { doc: '' },
108
+ 'GET /events': () => { asked = true; return new Response(new ReadableStream<Uint8Array>({ start(c) { c.enqueue(new TextEncoder().encode('event: permission_request\ndata: {"runId":"r1","projectId":"p1","data":{}}\n\n')); } }), { status: 200 }); },
109
+ });
110
+ const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_status').run({ runId: 'r1', wait_seconds: 5, until: 'needs_you' });
111
+ assert.match(r.text, /^needs you$/m);
112
+ assert.match(r.text, /\[perm\] director wants Bash/);
113
+ });
114
+
115
+ test('run_report: the director\'s last words, DONE WHEN, changed files and the branch in one call', async () => {
116
+ const say = (agent: string, text: string) => ({ ts: 1, event: 'message', data: { agent, msg: { type: 'assistant', message: { content: [{ type: 'text', text }] } } } });
117
+ const { fetchImpl } = fakeServer({
118
+ 'GET /runs': { runs: [run({ status: 'done', git: { branch: 'foreman/fix-r1', base: 'main', commits: 1, pr: 'https://github.com/o/r/pull/9', prState: 'merged' } })] },
119
+ 'GET /runs/r1/events': { events: [say('director', 'Starting.'), say('worker-1', 'A long worker message that must not be mistaken for the report.'), say('director', 'All done: tests pass, docs updated, nothing left undone in this mission.')] },
120
+ 'GET /missiondoc': { doc: '- [x] tests pass\n- [x] docs updated\n' },
121
+ 'GET /runs/r1/deck': { baseline: { kind: 'git' }, files: [{ path: 'src/a.ts', status: 'modified', additions: 10, deletions: 2 }, { path: 'old.txt', status: 'modified', additions: 1, deletions: 1, preexisting: true }], artifacts: [{ path: 'shot.png', kind: 'image' }], totals: { files: 2, additions: 11, deletions: 3 } },
122
+ });
123
+ const r = await tool(foremanTools({ base: 'http://f', fetchImpl }), 'run_report').run({ runId: 'r1' });
124
+ assert.match(r.text, /branch foreman\/fix-r1 from main · 1 commit · PR https:\/\/github.com\/o\/r\/pull\/9 \(merged\)/);
125
+ assert.match(r.text, /DONE WHEN 2\/2/);
126
+ assert.match(r.text, /changed: 1 file · \+11 −3 · 1 screenshot · 1 already dirty before the run/);
127
+ assert.match(r.text, /modified src\/a\.ts \+10 −2/);
128
+ assert.match(r.text, /Director's report:\nAll done: tests pass/);
129
+ assert.doesNotMatch(r.text, /worker message/);
130
+ });
131
+
82
132
  test('waitForRunEvent gives up at the timeout when nothing arrives for the run', async () => {
83
133
  const quiet = new ReadableStream<Uint8Array>({ start(c) { c.enqueue(new TextEncoder().encode(': connected\n\n')); } });
84
134
  const { fetchImpl } = fakeServer({ 'GET /events': new Response(quiet, { status: 200 }) });
@@ -114,7 +164,7 @@ test('the tool set has no human-only actions', () => {
114
164
  for (const forbidden of ['approve', 'deny', 'permission', 'answer', 'interrupt', 'resume', 'budget', 'pull_request', 'open_pr', 'settings', 'key']) {
115
165
  assert.ok(!names.some((n) => n.split('_').includes(forbidden) || n === forbidden), `${forbidden} must not be a tool`);
116
166
  }
117
- assert.deepEqual(names, ['fleet_status', 'list_runs', 'run_status', 'run_transcript', 'mission_doc', 'project_memory', 'search_runs', 'doctor', 'link_project', 'start_mission', 'steer']);
167
+ assert.deepEqual(names, ['fleet_status', 'list_runs', 'run_status', 'run_report', 'run_transcript', 'mission_doc', 'project_memory', 'search_runs', 'doctor', 'link_project', 'start_mission', 'steer']);
118
168
  });
119
169
 
120
170
  test('a server that is not there is said in one sentence with the start command', async () => {
package/src/mcp.ts CHANGED
@@ -222,22 +222,63 @@ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
222
222
  },
223
223
  };
224
224
 
225
+ /**
226
+ * What a reader of run_status would notice changing. `until: 'any'` waits
227
+ * for THIS to move, not for the next event: a fresh run emits a cost frame
228
+ * per SDK message, and returning on those meant "changed: true" twice in a
229
+ * row with identical payloads — a client had to poll after all.
230
+ */
231
+ async function digestOf(id: string): Promise<string> {
232
+ const r = await findRun(id);
233
+ const needs = await needsOf(id);
234
+ const doc = await get<{ doc: string }>(`/missiondoc?run=${encodeURIComponent(id)}`).then((d) => d.doc ?? '').catch(() => '');
235
+ const dw = doneWhen(doc);
236
+ return JSON.stringify([r.status, r.stopReason ?? null, (r.costUsd ?? 0).toFixed(2), (r.workers ?? []).map((w) => `${w.id}:${w.status}`), dw.done.length, dw.open.length, needs.map((n) => n.id), r.git?.commits ?? 0, r.git?.pr ?? null]);
237
+ }
238
+
239
+ /** What is pending on a run right now, from the fleet payload. */
240
+ async function needsOf(id: string): Promise<Need[]> {
241
+ const projects = (await get<{ projects: ProjectCard[] }>('/projects')).projects;
242
+ const card = projects.find((p) => p.activeRun?.id === id);
243
+ return (card?.needs ?? []).filter((n) => !n.runId || n.runId === id);
244
+ }
245
+
225
246
  const runStatus: ToolDef = {
226
247
  name: 'run_status',
227
- description: 'One run: status, spend against cap, crew and their states, DONE WHEN ticks, what needs a human, branch and pull request. With wait_seconds > 0 it returns as soon as anything changes on that run (or at the timeout) — use it instead of polling.',
228
- schema: { runId: z.string(), wait_seconds: z.number().int().min(0).max(maxWait).default(0) },
229
- run: async ({ runId, wait_seconds }) => {
248
+ description: 'One run: status, spend against cap, crew and their states, DONE WHEN ticks, what needs a human, branch and pull request. With wait_seconds > 0 it blocks until `until` is met — "any" change on the run, the run "finished", or it "needs_you" (a pending approval or question, or finished) — or until the timeout. Use it instead of polling; call again if it timed out.',
249
+ schema: {
250
+ runId: z.string(),
251
+ wait_seconds: z.number().int().min(0).max(maxWait).default(0),
252
+ until: z.enum(['any', 'finished', 'needs_you']).default('any'),
253
+ },
254
+ run: async ({ runId, wait_seconds, until }) => {
230
255
  const id = String(runId);
256
+ const cond = String(until ?? 'any');
231
257
  let changed: boolean | undefined;
232
258
  if (Number(wait_seconds) > 0) {
233
- const before = await findRun(id);
234
- if (before.status === 'running') changed = await waitForRunEvent(base, id, Number(wait_seconds), f);
235
- else changed = false;
259
+ // Wait in rounds: each round ends on the first event for the run,
260
+ // then the condition is checked against fresh state; an event that
261
+ // does not satisfy it (a cost tick, under "finished") starts another
262
+ // round with the time that is left. The clock is the outer bound.
263
+ const deadline = Date.now() + Number(wait_seconds) * 1000;
264
+ changed = false;
265
+ const before = await digestOf(id);
266
+ for (;;) {
267
+ const now = await findRun(id);
268
+ const satisfied = now.status !== 'running'
269
+ || (cond === 'needs_you' && (await needsOf(id)).length > 0)
270
+ || (cond === 'any' && changed);
271
+ if (satisfied) break;
272
+ const left = Math.ceil((deadline - Date.now()) / 1000);
273
+ if (left <= 0) break;
274
+ const got = await waitForRunEvent(base, id, left, f);
275
+ if (!got) break;
276
+ // An event arrived; only count it when the picture it paints differs.
277
+ if ((await digestOf(id)) !== before) changed = true;
278
+ }
236
279
  }
237
280
  const r = await findRun(id);
238
- const projects = (await get<{ projects: ProjectCard[] }>('/projects')).projects;
239
- const card = projects.find((p) => p.activeRun?.id === id);
240
- const needs = (card?.needs ?? []).filter((n) => !n.runId || n.runId === id);
281
+ const needs = await needsOf(id);
241
282
  const doc = await get<{ doc: string }>(`/missiondoc?run=${encodeURIComponent(id)}`).then((d) => d.doc).catch(() => '');
242
283
  const dw = doneWhen(doc);
243
284
  const workers = (r.workers ?? []).map((w) => ` ${w.id} · ${w.status} · ${usd(w.costUsd)} · ${w.task.slice(0, 80)}`);
@@ -248,9 +289,47 @@ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
248
289
  dw.done.length + dw.open.length ? `DONE WHEN ${dw.done.length}/${dw.done.length + dw.open.length}${dw.open.length ? `\n open: ${dw.open.join('\n open: ')}` : ''}` : null,
249
290
  workers.length ? `crew:\n${workers.join('\n')}` : null,
250
291
  needs.length ? `NEEDS YOU (${needs.length}) — only a human can answer these, on the dashboard or the phone:\n${needs.map((n) => ` [${n.kind}] ${n.text}`).join('\n')}` : null,
251
- changed !== undefined ? (changed ? 'changed: yes' : 'changed: no (timeout)') : null,
292
+ changed !== undefined ? (r.status !== 'running' ? 'finished' : needs.length && cond === 'needs_you' ? 'needs you' : changed ? 'changed: yes' : 'changed: no (timeout — call again)') : null,
293
+ ].filter(Boolean);
294
+ return { text: lines.join('\n'), data: { run: r, doneWhen: dw, needs, changed, waitedFor: Number(wait_seconds) > 0 ? cond : undefined } };
295
+ },
296
+ };
297
+
298
+ const runReport: ToolDef = {
299
+ name: 'run_report',
300
+ description: 'What a finished run produced, in one call: the director\'s final report, DONE WHEN ticks, the files it changed with +/− counts, the branch, commit and pull request, spend and crew. For a running run it reports the state so far.',
301
+ schema: { runId: z.string() },
302
+ run: async ({ runId }) => {
303
+ const id = String(runId);
304
+ const r = await findRun(id);
305
+ const [events, docRes, deck] = await Promise.all([
306
+ get<{ events: Array<{ ts: number; event: string; data: Record<string, unknown> }> }>(`/runs/${encodeURIComponent(id)}/events`).then((d) => d.events).catch(() => []),
307
+ get<{ doc: string }>(`/missiondoc?run=${encodeURIComponent(id)}`).catch(() => ({ doc: '' })),
308
+ get<{ files: Array<{ path: string; status: string; additions: number; deletions: number; preexisting?: boolean }>; artifacts: Array<{ path: string; kind: string }>; totals: { files: number; additions: number; deletions: number }; baseline: { kind: string } }>(`/runs/${encodeURIComponent(id)}/deck`).catch(() => null),
309
+ ]);
310
+ // The director's last words: the final assistant text before the run ended.
311
+ let report = '';
312
+ for (const e of events) {
313
+ if (e.event !== 'message' || e.data?.agent !== 'director') continue;
314
+ const msg = e.data.msg as { type?: string; message?: { content?: Array<{ type: string; text?: string }> } } | undefined;
315
+ const text = msg?.type === 'assistant' ? msg.message?.content?.filter((c) => c.type === 'text' && c.text).map((c) => c.text).join('\n') : '';
316
+ if (text && text.trim().length > 40) report = text.trim();
317
+ }
318
+ const dw = doneWhen(docRes.doc);
319
+ const files = deck?.files ?? [];
320
+ const own = files.filter((x) => !x.preexisting);
321
+ const fileLines = own.slice(0, 40).map((x) => ` ${x.status.padEnd(8)} ${x.path} +${x.additions} −${x.deletions}`);
322
+ const images = (deck?.artifacts ?? []).filter((a) => a.kind === 'image').length;
323
+ const lines = [
324
+ runLine(r),
325
+ r.git ? `branch ${r.git.branch} from ${r.git.base}${r.git.commits ? ` · ${r.git.commits} commit${r.git.commits === 1 ? '' : 's'}` : ''}${r.git.pr ? ` · PR ${r.git.pr}${r.git.prState ? ` (${r.git.prState})` : ''}` : ' · no pull request yet (the human opens it from the run page)'}` : 'not a git repository',
326
+ `DONE WHEN ${dw.done.length}/${dw.done.length + dw.open.length}${dw.open.length ? ` · open: ${dw.open.join(' · ')}` : ''}`,
327
+ deck ? `changed: ${own.length} file${own.length === 1 ? '' : 's'} · +${deck.totals.additions} −${deck.totals.deletions}${images ? ` · ${images} screenshot${images === 1 ? '' : 's'}` : ''}${files.length > own.length ? ` · ${files.length - own.length} already dirty before the run` : ''}` : null,
328
+ fileLines.length ? fileLines.join('\n') + (own.length > 40 ? `\n … ${own.length - 40} more` : '') : null,
329
+ `crew: ${(r.workers ?? []).length} worker${(r.workers ?? []).length === 1 ? '' : 's'} · ${(r.workers ?? []).filter((w) => w.status === 'done').length} done`,
330
+ report ? `\nDirector's report:\n${report.slice(0, 4000)}` : '\nNo final report from the director yet.',
252
331
  ].filter(Boolean);
253
- return { text: lines.join('\n'), data: { run: r, doneWhen: dw, needs, changed } };
332
+ return { text: lines.join('\n'), data: { run: r, doneWhen: dw, files: own, totals: deck?.totals, report } };
254
333
  },
255
334
  };
256
335
 
@@ -377,7 +456,7 @@ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
377
456
  },
378
457
  };
379
458
 
380
- return [fleet, listRuns, runStatus, transcript, missionDoc, memory, search, doctor, link, start, steer];
459
+ return [fleet, listRuns, runStatus, runReport, transcript, missionDoc, memory, search, doctor, link, start, steer];
381
460
  }
382
461
 
383
462
  /** Runs the MCP server over stdio until the client goes away. Nothing may be written to stdout but the protocol. */
@@ -604,6 +604,8 @@ type AgentRole = 'director' | 'worker';
604
604
  * and which role's rates price the tokens.
605
605
  */
606
606
  interface WorkerOverrides {
607
+ /** Set on the one continuation a worker gets after the turn cap. */
608
+ continued?: boolean;
607
609
  env?: AgentEnv;
608
610
  model?: string;
609
611
  priceRole?: AgentRole;
@@ -746,6 +748,19 @@ const USAGE_LIMIT_RE = /out of usage credits|usage limit reached|upgrade to incr
746
748
  /** One worker's outcome, as runWorker hands it back. */
747
749
  interface WorkerOutcome { report: string; isError: boolean }
748
750
 
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
+ /** What a worker stopped by the cap is told when its session is picked back up. */
759
+ const WORKER_CONTINUE_PROMPT =
760
+ 'You were stopped by the turn cap, not by a failure. Your session and your files are as you left them; ' +
761
+ '`git status` and `git diff` show your uncommitted work. Continue the same task from where you were — do not ' +
762
+ 'start over or re-read what you already know — finish it, report with report_progress, and end your turn.';
763
+
749
764
  /**
750
765
  * A worker record plus the in-process handles that must never be persisted:
751
766
  * the live query (for interrupts), the run promise (so nothing is left
@@ -1983,7 +1998,7 @@ export class MissionRun {
1983
1998
  permissionMode: 'default',
1984
1999
  resume: resumeSessionId,
1985
2000
  model: overrides?.model || this.meta.workerModel,
1986
- maxTurns: 60,
2001
+ maxTurns: WORKER_MAX_TURNS,
1987
2002
  systemPrompt: { type: 'preset', preset: 'claude_code', append: WORKER_CHARTER },
1988
2003
  ...(overrides?.env ?? this.agentEnv.worker),
1989
2004
  // Built per worker: the report_progress handler closes over this id,
@@ -1996,6 +2011,7 @@ export class MissionRun {
1996
2011
  w.q = q;
1997
2012
 
1998
2013
  let report = '';
2014
+ let hitTurnCap = false;
1999
2015
  let isError = false;
2000
2016
 
2001
2017
  // The stall watchdog. A silent worker no longer blocks the director, but
@@ -2041,6 +2057,7 @@ export class MissionRun {
2041
2057
  if (m.type === 'result') {
2042
2058
  report = String(m.result ?? '');
2043
2059
  isError = Boolean(m.is_error);
2060
+ hitTurnCap = m.subtype === 'error_max_turns' || /maximum number of turns/i.test(report);
2044
2061
  this.noteUsageLimit(report);
2045
2062
  // Same ordering rule as the director loop: the cost event carries
2046
2063
  // usage, so usage has to be current before it is emitted.
@@ -2060,6 +2077,16 @@ export class MissionRun {
2060
2077
  w.q = undefined;
2061
2078
  }
2062
2079
 
2080
+ // A worker stopped by the turn cap mid-task is not a failed worker. Its
2081
+ // session is resumed once with a continue turn — the same context, the
2082
+ // same files — instead of handing the director an error it can only
2083
+ // answer by spawning a replacement that re-reads everything. Once: a
2084
+ // worker that burns two allowances is stuck, and that IS the director's.
2085
+ if (hitTurnCap && !overrides?.continued && w.sessionId && !stalled && !looping) {
2086
+ this.emit('worker_progress', { id: workerId, status: `reached the ${WORKER_MAX_TURNS}-turn cap mid-task; continuing the same session once` });
2087
+ return this.runWorker(workerId, WORKER_CONTINUE_PROMPT, w.sessionId, { ...overrides, continued: true });
2088
+ }
2089
+
2063
2090
  // Told to the director as a fact plus its options, not as an order: it is
2064
2091
  // the agent with the context to know whether this needs a different
2065
2092
  // approach, a different worker, or a human.
package/src/server.ts CHANGED
@@ -72,7 +72,8 @@ import { readMemory } from './memory.js';
72
72
  import { budgetAnchor, modelRecords, projectRecord, recordLine } from './track-record.js';
73
73
  import { reconcileRole } from './role-provider.js';
74
74
  import { detectBrowser, installChromium } from './browser.js';
75
- import { closeMissionBranch, compareUrl, createPullRequest, ensureMissionBranch, ghReady, gitInfo, prDraft, pullRequestState, pushBranch, startMissionBranch, type GitInfo } from './gitwork.js';
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
77
  import { detectTailscale, tailnetUrl } from './tailscale.js';
77
78
  import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
78
79
  import { ServiceRegistry, portOpen, servicesHandler } from './services.js';
@@ -593,6 +594,21 @@ function makeEmitter(runId: string, projectId: string) {
593
594
  runLabelCache.set(runId, { ...runLabelCache.get(runId), mission: String(d.mission ?? '') });
594
595
  } else if (event === 'run_titled') {
595
596
  runLabelCache.set(runId, { ...runLabelCache.get(runId), title: String(d.title ?? '') });
597
+ // The branch was named from the brief before the title existed; now it
598
+ // can carry the title. Only while it is still the brief-derived name and
599
+ // nothing is committed on it — the mission's first turns.
600
+ const run = activeRuns().find((r) => r.meta.id === runId);
601
+ const title = String(d.title ?? '').trim();
602
+ if (run?.meta.git && title && run.meta.git.branch === missionBranchName(run.meta.mission, runId) && !(run.meta.git.commits)) {
603
+ void renameMissionBranch(run.meta.folder, run.meta.git.branch, title, runId).then(async (renamed) => {
604
+ if (!renamed || !run.meta.git) return;
605
+ const was = run.meta.git.branch;
606
+ run.meta.git = { ...run.meta.git, branch: renamed };
607
+ await store.writeMeta(run.meta).catch(() => {});
608
+ gitInfoCache.delete(run.meta.folder);
609
+ makeEmitter(runId, projectId)('git_branch', { branch: renamed, base: run.meta.git.base, text: `Branch renamed to ${renamed} (was ${was}) now that the run has a title.` });
610
+ });
611
+ }
596
612
  }
597
613
  if (!projectsCache.has(projectId)) {
598
614
  void store.getProject(projectId).then((p) => { if (p) projectsCache.set(projectId, { name: p.name }); });
@@ -1595,6 +1611,15 @@ async function driveRun(
1595
1611
  // However the run ended, it no longer needs its gateways.
1596
1612
  releaseGateways(meta.id);
1597
1613
  if (activeByProject.get(projectId) === run) activeByProject.delete(projectId);
1614
+ // Freeze the record: the mission doc and the deck as they stand at this
1615
+ // moment, beside the run's meta and log. Done first, before the branch
1616
+ // closes, so the record is what the crew left — a later mission in the
1617
+ // same folder rewrites MISSION.md and the live diff, never this copy.
1618
+ const frozen = await snapshotRun(store.runDirectory(meta.id), meta.folder, meta.id);
1619
+ if (frozen.doc || frozen.deck) {
1620
+ meta.snapshotAt = Date.now();
1621
+ await store.writeMeta(meta).catch(() => {});
1622
+ }
1598
1623
  // A finished mission on its own branch closes with a commit of whatever
1599
1624
  // the crew left uncommitted. Done only: an interrupted run resumes on
1600
1625
  // the same branch and its tree, and an error is not a result to record.
@@ -1681,6 +1706,7 @@ async function startRun(
1681
1706
  // choosing a model chooses where that role runs.
1682
1707
  directorProviderId: roleProviders.director ?? settings.directorProviderId,
1683
1708
  workerProviderId: roleProviders.worker ?? settings.workerProviderId,
1709
+ ownerPid: process.pid,
1684
1710
  browserTools: browserTools || undefined,
1685
1711
  toolPolicy: settings.toolPolicy,
1686
1712
  autoAllowReadOnly: settings.autoAllowReadOnly,
@@ -1699,6 +1725,14 @@ async function startRun(
1699
1725
  return;
1700
1726
  }
1701
1727
  await consumeProposal(projectId, meta.id, mission).catch(() => {});
1728
+ // The folder's MISSION.md belongs to whichever run wrote it. Parked into
1729
+ // that run's record (when it lacks one) and cleared, so this run's status
1730
+ // reads empty until its own director writes the plan — not 13/14 done.
1731
+ {
1732
+ const previous = (await store.listRuns().catch(() => [] as RunMeta[]))
1733
+ .find((r) => r.projectId === projectId && r.id !== meta.id && r.status !== 'running');
1734
+ await parkMissionDoc(folder, previous ? store.runDirectory(previous.id) : null).catch(() => {});
1735
+ }
1702
1736
  // In a repository, the mission gets a branch of its own before the crew
1703
1737
  // touches anything — so the deck's baseline, taken at the director's first
1704
1738
  // turn, is the branch point, and the diff is exactly the mission.
@@ -1761,6 +1795,7 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
1761
1795
  meta.stopReason = undefined;
1762
1796
  meta.status = 'running';
1763
1797
  meta.endedAt = undefined;
1798
+ meta.ownerPid = process.pid;
1764
1799
  meta.resumes = (meta.resumes ?? 0) + 1;
1765
1800
  if (meta.budgetUsd !== budgetWas) {
1766
1801
  makeEmitter(meta.id, projectId)('settings_changed', {
@@ -1773,6 +1808,15 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
1773
1808
  if (back) makeEmitter(meta.id, projectId)('git_note', { text: `Could not return to ${meta.git.branch} (${back}); the resumed mission runs on whatever is checked out.` });
1774
1809
  gitInfoCache.delete(meta.folder);
1775
1810
  }
1811
+ // The director resumes from MISSION.md. If another mission ran here since,
1812
+ // the folder's copy is that mission's; this run's own goes back first.
1813
+ const restored = await restoreMissionDoc(store.runDirectory(meta.id), meta.folder).catch(() => 'none' as const);
1814
+ if (restored === 'restored') {
1815
+ const emit = makeEmitter(meta.id, projectId);
1816
+ emit('mission_doc_restored', {
1817
+ text: 'Restored this run\'s own MISSION.md into the folder before resuming; a later mission had overwritten it.',
1818
+ });
1819
+ }
1776
1820
  await store.writeMeta(meta).catch((err) => {
1777
1821
  console.error(`failed to persist resume of ${meta.id}:`, err);
1778
1822
  });
@@ -1895,7 +1939,9 @@ const server = http.createServer(async (req, res) => {
1895
1939
  return project ? { folder: project.folder } : null;
1896
1940
  }
1897
1941
  const m = await store.readMeta(id).catch(() => null);
1898
- return m ? { folder: m.folder } : null;
1942
+ if (!m) return null;
1943
+ const frozen = m.status !== 'running' ? await frozenDeck(store.runDirectory(m.id)) : null;
1944
+ return { folder: m.folder, ...(frozen ? { deck: frozen } : {}) };
1899
1945
  })) return;
1900
1946
  const runResumeMatch = url.pathname.match(/^\/runs\/([^/]+)\/resume$/);
1901
1947
  const prMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr$/);
@@ -2779,9 +2825,11 @@ const server = http.createServer(async (req, res) => {
2779
2825
  if (!runId) return json(res, 400, { error: 'run parameter is required' });
2780
2826
  const meta = await store.readMeta(runId);
2781
2827
  if (!meta) return json(res, 404, { error: 'unknown run' });
2782
- const doc = await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8')
2783
- .catch(() => null);
2784
- json(res, 200, { doc });
2828
+ // A finished run answers with the doc as it ended; only a running run
2829
+ // reads the folder, which is the one mission running there right now.
2830
+ const frozen = meta.status !== 'running' ? await frozenMissionDoc(store.runDirectory(meta.id)) : null;
2831
+ const doc = frozen ?? await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null);
2832
+ json(res, 200, { doc, frozen: frozen !== null });
2785
2833
 
2786
2834
  } else if (req.method === 'GET' && url.pathname === '/browse') {
2787
2835
  const requested = url.searchParams.get('path') || os.homedir();
@@ -0,0 +1,68 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { frozenDeck, frozenMissionDoc, hasSnapshot, parkMissionDoc, restoreMissionDoc, snapshotRun } from './snapshot.js';
7
+
8
+ async function site() {
9
+ const root = await mkdtemp(path.join(os.tmpdir(), 'foreman-snap-'));
10
+ const folder = path.join(root, 'proj'); const runDir = path.join(root, 'runs', 'r1');
11
+ await mkdir(path.join(folder, '.foreman'), { recursive: true });
12
+ await mkdir(runDir, { recursive: true });
13
+ return { root, folder, runDir };
14
+ }
15
+
16
+ test('snapshotRun freezes the mission doc and a deck beside the run, and reads them back', async () => {
17
+ const { root, folder, runDir } = await site();
18
+ await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Mission A\n- [x] done\n');
19
+ await writeFile(path.join(folder, 'a.txt'), 'hello\n');
20
+ const r = await snapshotRun(runDir, folder, 'r1', 1234);
21
+ assert.deepEqual(r, { doc: true, deck: true });
22
+ assert.equal(await frozenMissionDoc(runDir), '# Mission A\n- [x] done\n');
23
+ const deck = await frozenDeck(runDir);
24
+ assert.equal(deck?.frozenAt, 1234);
25
+ assert.match(deck?.note ?? '', /As the folder stood when the run ended/);
26
+ assert.equal(await hasSnapshot(runDir), true);
27
+ // A later mission overwrites the folder's doc; the record does not move.
28
+ await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Mission B\n');
29
+ assert.equal(await frozenMissionDoc(runDir), '# Mission A\n- [x] done\n');
30
+ await rm(root, { recursive: true, force: true });
31
+ });
32
+
33
+ test('restoreMissionDoc puts the run\'s own doc back, and says whether it had to', async () => {
34
+ const { root, folder, runDir } = await site();
35
+ assert.equal(await restoreMissionDoc(runDir, folder), 'none', 'no record yet');
36
+ await writeFile(path.join(runDir, 'MISSION.md'), '# Mission A\n');
37
+ await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Mission B\n');
38
+ assert.equal(await restoreMissionDoc(runDir, folder), 'restored');
39
+ assert.equal(await readFile(path.join(folder, '.foreman', 'MISSION.md'), 'utf8'), '# Mission A\n');
40
+ assert.equal(await restoreMissionDoc(runDir, folder), 'same');
41
+ await rm(root, { recursive: true, force: true });
42
+ });
43
+
44
+ test('a run with no mission doc still freezes its deck; a missing folder freezes nothing and does not throw', async () => {
45
+ const { root, folder, runDir } = await site();
46
+ const r = await snapshotRun(runDir, folder, 'r1');
47
+ assert.equal(r.doc, false);
48
+ assert.equal(await hasSnapshot(runDir), r.deck);
49
+ const gone = await snapshotRun(path.join(root, 'runs', 'r2'), path.join(root, 'nowhere'), 'r2');
50
+ assert.equal(gone.doc, false);
51
+ assert.equal(await hasSnapshot(path.join(root, 'runs', 'r2')), gone.deck);
52
+ await rm(root, { recursive: true, force: true });
53
+ });
54
+
55
+
56
+ test('parkMissionDoc clears the folder for a new run and keeps the old doc with the run that wrote it', async () => {
57
+ const { root, folder, runDir } = await site();
58
+ assert.equal(await parkMissionDoc(folder, runDir), 'none', 'nothing to park');
59
+ await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Old mission\n- [x] all done\n');
60
+ assert.equal(await parkMissionDoc(folder, runDir), 'parked');
61
+ assert.equal(await readFile(path.join(folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null), null, 'the folder starts clean');
62
+ assert.equal(await frozenMissionDoc(runDir), '# Old mission\n- [x] all done\n', 'kept with the previous run');
63
+ // A previous run that already has its own copy is not overwritten by a later folder state.
64
+ await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Something else\n');
65
+ await parkMissionDoc(folder, runDir);
66
+ assert.equal(await frozenMissionDoc(runDir), '# Old mission\n- [x] all done\n');
67
+ await rm(root, { recursive: true, force: true });
68
+ });