@amenophis1er/foreman 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -50,6 +50,14 @@
50
50
  * DELETE /chat?projectId= Forget the conversation and its session
51
51
  * PUT /providers/{id}/key Store a provider's key {key}
52
52
  * DELETE /providers/{id}/key Forget it
53
+ * GET /projects/{id}/schedules This project's schedules + this month's scheduled spend and its ceiling
54
+ * POST /projects/{id}/schedules Add one {name, brief, cadence, budgetUsd, …} → {schedule}
55
+ * PUT /schedules/{id} Change one (same fields, all optional) → {schedule}
56
+ * DELETE /schedules/{id} Forget one
57
+ * POST /schedules/{id}/pause Stop it firing until a human resumes it
58
+ * POST /schedules/{id}/resume Start it firing again, from now
59
+ * POST /schedules/{id}/run-now Start its mission at once (409 if the project is busy)
60
+ * GET /schedules/preview?cadence= The next three firings of a cadence {next:[ms]}
53
61
  */
54
62
  import http from 'node:http';
55
63
  import crypto from 'node:crypto';
@@ -73,14 +81,14 @@ import { budgetAnchor, modelRecords, projectRecord, recordLine } from './track-r
73
81
  import { reconcileRole } from './role-provider.js';
74
82
  import { detectBrowser, installChromium } from './browser.js';
75
83
  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';
84
+ import { closeMissionBranch, compareUrl, createPullRequest, dirtyPaths, ensureMissionBranch, ghReady, gitInfo, resolvePrBase, worktreeGrant, worktreeParent, missionBranchName, prDraft, pullRequestState, pushBranch, renameMissionBranch, startMissionBranch, type GitInfo } from './gitwork.js';
77
85
  import { detectTailscale, tailnetUrl } from './tailscale.js';
78
86
  import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
79
- import { ServiceRegistry, portOpen, servicesHandler } from './services.js';
87
+ import { ServiceRegistry, listeningPid, portOpen, servicesHandler, stopService } from './services.js';
80
88
  import { HELP_TEXT, expandHome, parseCommand, projectsRoot, slug } from './notify/commands.js';
81
89
  import {
82
90
  DEFAULT_FLEET_MODEL, FLEET_CHAT_ID, PHONE_CONTEXT_MS, phoneRoute, runFleetTurn,
83
- type FleetHost, type FleetProjectView,
91
+ type FleetHost, type FleetProjectView, type FleetScheduleView,
84
92
  } from './fleet-planner.js';
85
93
  import { escapeHtml as escTg } from './notify.js';
86
94
  import { RunStore, newRunId } from './store.js';
@@ -107,8 +115,12 @@ import {
107
115
  dirHasCredentials, detectAuth, hasKeychainCredentials, readAccount, type AuthMode,
108
116
  } from './preflight.js';
109
117
  import { combineBasis, costBasisOf } from './types.js';
118
+ import { describeCadence, nextRunAt, nextRuns, validateCadence, type Cadence } from './schedule.js';
119
+ import {
120
+ DEFAULT_SCHEDULED_MONTHLY_CAP_USD, afterRunOutcome, decideTicks, monthlyScheduledSpend,
121
+ } from './schedule-guards.js';
110
122
  import type {
111
- ChatMeta, CostBasis, ForemanEvent, MissionProposal, ModelChoice, Project, ProviderRef, RunMeta, ToolPolicy,
123
+ ChatMeta, CostBasis, ForemanEvent, MissionProposal, ModelChoice, Project, ProviderRef, RunMeta, Schedule, ToolPolicy,
112
124
  } from './types.js';
113
125
 
114
126
  /**
@@ -535,6 +547,24 @@ function activeRuns(): MissionRun[] {
535
547
  return [...activeByProject.values()].filter((r): r is MissionRun => Boolean(r));
536
548
  }
537
549
 
550
+ /**
551
+ * The metadata of every run this process is currently driving, by run id.
552
+ *
553
+ * The emitter has to answer "was this run started by a schedule?" at the
554
+ * instant it emits, and a run's `run_finished` can be emitted before the
555
+ * MissionRun object exists at all (a provider that will not resolve fails the
556
+ * run in driveRun's first few lines). Reading meta.json back there would be
557
+ * asynchronous, and the notification envelope has already gone out by then.
558
+ */
559
+ const drivingRuns = new Map<string, RunMeta>();
560
+
561
+ /**
562
+ * Schedule names by schedule id, so a notification can say which standing
563
+ * instruction started a run without a disk read on the emitter's path. Filled
564
+ * wherever schedules are loaded anyway — every ticker pass, and each dispatch.
565
+ */
566
+ const scheduleNames = new Map<string, string>();
567
+
538
568
  /**
539
569
  * What happened in the fleet lately, in one line each, for the front desk.
540
570
  *
@@ -610,6 +640,21 @@ function makeEmitter(runId: string, projectId: string) {
610
640
  });
611
641
  }
612
642
  }
643
+ // The end of a scheduled run is also the schedule's news: the phone should
644
+ // say nobody pressed start, and the schedule's own failure bookkeeping has
645
+ // to move on. This is where the server learns a run ended — the
646
+ // orchestrator knows nothing about schedules and should not.
647
+ if (event === 'run_finished') {
648
+ const meta = drivingRuns.get(runId) ?? activeRuns().find((r) => r.meta.id === runId)?.meta;
649
+ if (meta?.scheduleId) {
650
+ d.scheduled = true;
651
+ const name = scheduleNames.get(meta.scheduleId);
652
+ if (name) d.scheduleName = name;
653
+ void recordScheduleOutcome(meta.scheduleId, meta).catch((err) => {
654
+ console.error(`failed to record outcome of schedule ${meta.scheduleId}:`, err);
655
+ });
656
+ }
657
+ }
613
658
  if (!projectsCache.has(projectId)) {
614
659
  void store.getProject(projectId).then((p) => { if (p) projectsCache.set(projectId, { name: p.name }); });
615
660
  }
@@ -617,6 +662,24 @@ function makeEmitter(runId: string, projectId: string) {
617
662
  };
618
663
  }
619
664
 
665
+ /**
666
+ * An announcement about a schedule rather than about a run.
667
+ *
668
+ * A skipped or auto-paused schedule has no run to hang a line off — that is
669
+ * exactly what happened: nothing started. So the frame carries `runId: null`,
670
+ * goes nowhere near a run's event log, and otherwise travels the same three
671
+ * roads every other event does (open tabs, the front desk's news, the phone).
672
+ */
673
+ function scheduleNotice(projectId: string) {
674
+ return (event: string, data: unknown): void => {
675
+ const frame = `event: ${event}\ndata: ${JSON.stringify({ runId: null, projectId, data })}\n\n`;
676
+ for (const res of sseClients) res.write(frame);
677
+ const d = (data ?? {}) as Record<string, unknown>;
678
+ noteFleetEvent(projectId, event, d);
679
+ notifyHub.handle({ event, runId: null, projectId, data: d, ts: Date.now() });
680
+ };
681
+ }
682
+
620
683
  /**
621
684
  * Everything an agent needs to authenticate, including starting the provider's
622
685
  * gateway if it has one.
@@ -805,12 +868,24 @@ notifyHub.onAnswer((a) => {
805
868
  // brief, its budget, its models and its browser judgement.
806
869
  void startRun(a.projectId, project.folder, prop.mission, prop.budgetUsd,
807
870
  modelChoice(prop.directorModel), modelChoice(prop.workerModel), prop.browser === true,
808
- providerOf(project), { director: prop.directorProviderId, worker: prop.workerProviderId });
871
+ providerOf(project), { director: prop.directorProviderId, worker: prop.workerProviderId },
872
+ { startedBy: 'phone' });
809
873
  void notifyHub.say(`Started <b>${escTg(project.name)}</b> as proposed, cap $${prop.budgetUsd}.`);
810
874
  })();
811
875
  }
812
876
  });
813
877
 
878
+ /**
879
+ * A gap as a reader would say it: "in 15 h", "3 days ago". Used where a
880
+ * timestamp would make someone do arithmetic on their phone.
881
+ */
882
+ function relativeTime(ms: number): string {
883
+ const s = Math.round(ms / 1000);
884
+ const a = Math.abs(s);
885
+ const span = a < 90 ? 'a minute' : a < 5400 ? `${Math.round(a / 60)} min` : a < 172800 ? `${Math.round(a / 3600)} h` : `${Math.round(a / 86400)} days`;
886
+ return s >= 0 ? `in ${span}` : `${span} ago`;
887
+ }
888
+
814
889
  /** The project the phone last planned with; plain text continues it. */
815
890
  let lastPhonePlanning: string | null = null;
816
891
 
@@ -959,6 +1034,41 @@ const fleetHost: FleetHost = {
959
1034
  return out;
960
1035
  },
961
1036
 
1037
+ /**
1038
+ * The standing schedules, with what they have already cost this month
1039
+ * against their project's ceiling — the number that decides whether the next
1040
+ * unattended firing happens at all, and the one nobody can see from a phone.
1041
+ *
1042
+ * Reading only, like every other verb here. An unknown reference is an
1043
+ * error rather than an empty list: "no schedules" and "you named a project
1044
+ * that does not exist" are different answers, and a typo must not read as
1045
+ * reassurance.
1046
+ */
1047
+ async listSchedules(ref?: string): Promise<FleetScheduleView[]> {
1048
+ const project = ref ? await findProject(ref) : null;
1049
+ if (ref && !project) throw new Error(await noSuchProject(ref));
1050
+ const schedules = await store.listSchedules(project?.id);
1051
+ if (!schedules.length) return [];
1052
+ const runs = await store.listRuns().catch(() => [] as RunMeta[]);
1053
+ const now = new Date();
1054
+ const names = new Map((await store.listProjects()).map((p) => [p.id, p.name]));
1055
+ // One settings read and one spend count per project, not per schedule:
1056
+ // a project with six nightly schedules asks the same two questions six
1057
+ // times, and the answers cannot differ between them.
1058
+ const money = new Map<string, { monthSpendUsd: number; monthlyCapUsd: number }>();
1059
+ for (const projectId of new Set(schedules.map((s) => s.projectId))) {
1060
+ money.set(projectId, {
1061
+ monthSpendUsd: monthlyScheduledSpend(runs, projectId, now),
1062
+ monthlyCapUsd: (await effectiveSettings(projectId)).scheduledMonthlyCapUsd,
1063
+ });
1064
+ }
1065
+ return schedules.map((schedule) => ({
1066
+ projectName: names.get(schedule.projectId) ?? schedule.projectId,
1067
+ schedule,
1068
+ ...money.get(schedule.projectId)!,
1069
+ }));
1070
+ },
1071
+
962
1072
  async projectDetail(ref) {
963
1073
  const project = await findProject(ref);
964
1074
  if (!project) return noSuchProject(ref);
@@ -1246,9 +1356,35 @@ async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
1246
1356
  if (!reserveProject(project.id)) return say(`<b>${escTg(project.name)}</b> already has an active mission.`);
1247
1357
  void startRun(project.id, project.folder, cmd.text, project.defaultBudgetUsd,
1248
1358
  modelChoice(undefined), modelChoice(undefined), /screenshot|browser|render|console/i.test(cmd.text),
1249
- providerOf(project));
1359
+ providerOf(project), {}, { startedBy: 'phone' });
1250
1360
  return say(`Started a mission on <b>${escTg(project.name)}</b> with a $${project.defaultBudgetUsd} cap. I will tell you when it needs you or ends.`);
1251
1361
  }
1362
+ case 'schedules': {
1363
+ // Reading only. A schedule is standing configuration — it decides
1364
+ // what a machine does while nobody is watching — and remote surfaces
1365
+ // never grant standing changes, so there is no create, edit, pause,
1366
+ // resume or run-now here. The reply says so rather than leaving
1367
+ // someone to discover it by trying.
1368
+ const project = cmd.project ? await findProject(cmd.project) : null;
1369
+ if (cmd.project && !project) return say(`No project called <b>${escTg(cmd.project)}</b>. /projects lists them.`);
1370
+ const schedules = await store.listSchedules(project?.id);
1371
+ const footer = 'Schedules are read-only from here — create, edit, pause and resume live in the dashboard.';
1372
+ if (!schedules.length) {
1373
+ return say(`No schedules${project ? ` on <b>${escTg(project.name)}</b>` : ''} yet.\n${footer}`);
1374
+ }
1375
+ const names = new Map((await store.listProjects()).map((p) => [p.id, p.name]));
1376
+ const lines = schedules.map((s) => {
1377
+ const state = s.pausedReason
1378
+ ? `paused (${s.pausedReason === 'monthly-cap' ? 'monthly cap' : s.pausedReason})`
1379
+ : s.enabled ? 'enabled' : 'disabled';
1380
+ const next = s.pausedReason || !s.enabled || s.nextRunAt === null
1381
+ ? 'no next run'
1382
+ : `next ${relativeTime(s.nextRunAt - Date.now())}`;
1383
+ const where = project ? '' : ` · ${escTg(names.get(s.projectId) ?? s.projectId)}`;
1384
+ return `• <b>${escTg(s.name)}</b>${where}\n ${escTg(describeCadence(s.cadence))} · ${next} · ${state}`;
1385
+ });
1386
+ return say(`<b>Schedules${project ? ` · ${escTg(project.name)}` : ''}</b>\n${lines.join('\n')}\n\n${footer}`);
1387
+ }
1252
1388
  case 'stop': {
1253
1389
  const project = cmd.project ? await findProject(cmd.project) : (lastPhonePlanning ? await store.getProject(lastPhonePlanning) : null);
1254
1390
  const abort = project && chatAborts.get(project.id);
@@ -1451,6 +1587,9 @@ async function driveRun(
1451
1587
  changes?: { directorChanged: boolean; workerChanged: boolean },
1452
1588
  ): Promise<void> {
1453
1589
  const emit = makeEmitter(meta.id, projectId);
1590
+ // Registered before anything can fail, so even a run that dies on its
1591
+ // provider still tells the emitter which schedule it belonged to.
1592
+ drivingRuns.set(meta.id, meta);
1454
1593
  // One resolution per run, from the provider frozen into the run's metadata.
1455
1594
  // A run that cannot resolve a credential must not start: dispatching anyway
1456
1595
  // would fall back to whatever the environment happens to hold.
@@ -1462,6 +1601,7 @@ async function driveRun(
1462
1601
  await store.writeMeta(meta).catch(() => {});
1463
1602
  emit('run_error', { error: `provider unavailable — ${problem}` });
1464
1603
  emit('run_finished', { status: 'error', costUsd: meta.costUsd });
1604
+ drivingRuns.delete(meta.id);
1465
1605
  activeByProject.delete(projectId);
1466
1606
  return;
1467
1607
  }
@@ -1543,6 +1683,7 @@ async function driveRun(
1543
1683
  await store.writeMeta(meta).catch(() => {});
1544
1684
  emit('run_error', { error: String(err instanceof Error ? err.message : err) });
1545
1685
  emit('run_finished', { status: 'error', costUsd: meta.costUsd });
1686
+ drivingRuns.delete(meta.id);
1546
1687
  activeByProject.delete(projectId);
1547
1688
  return;
1548
1689
  }
@@ -1578,8 +1719,11 @@ async function driveRun(
1578
1719
  exposeService: async (runId, port, label) => {
1579
1720
  if (servicesDown) return { ok: false, reason: servicesDown };
1580
1721
  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 };
1722
+ // Whoever holds the port right now is what the crew just started, and
1723
+ // the only process a later stop is allowed to touch.
1724
+ const pid = await listeningPid(port);
1725
+ const svc = services.register(runId, port, label, pid ?? undefined);
1726
+ return { ok: true, url: `${await servicesBase()}${svc.path}`, path: svc.path, pid: pid ?? undefined };
1583
1727
  },
1584
1728
  });
1585
1729
  activeByProject.set(projectId, run);
@@ -1610,6 +1754,7 @@ async function driveRun(
1610
1754
  } finally {
1611
1755
  // However the run ended, it no longer needs its gateways.
1612
1756
  releaseGateways(meta.id);
1757
+ drivingRuns.delete(meta.id);
1613
1758
  if (activeByProject.get(projectId) === run) activeByProject.delete(projectId);
1614
1759
  // Freeze the record: the mission doc and the deck as they stand at this
1615
1760
  // moment, beside the run's meta and log. Done first, before the branch
@@ -1648,8 +1793,12 @@ async function effectiveSettings(projectId: string): Promise<{
1648
1793
  directorProviderId?: string; workerProviderId?: string;
1649
1794
  /** In a repository, each mission runs on a branch of its own (default on). */
1650
1795
  gitBranchPerMission: boolean;
1796
+ /** A mission in a worktree may use its parent repository without asking (default on). */
1797
+ allowWorktreeParent: boolean;
1651
1798
  /** Percent of the cap at which the director is told to start verifying (default 80). */
1652
1799
  budgetWarnAt: number;
1800
+ /** Ceiling on what this project's SCHEDULED runs may cost in one calendar month. */
1801
+ scheduledMonthlyCapUsd: number;
1653
1802
  }> {
1654
1803
  const s = await store.readSettings()
1655
1804
  .catch(() => ({ global: {}, projects: {} as Record<string, object> }));
@@ -1671,9 +1820,16 @@ async function effectiveSettings(projectId: string): Promise<{
1671
1820
  directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
1672
1821
  workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
1673
1822
  gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
1823
+ allowWorktreeParent: (p.allowWorktreeParent ?? g.allowWorktreeParent) !== false,
1674
1824
  budgetWarnAt: (() => {
1675
1825
  const raw = Number(p.budgetWarnAt ?? g.budgetWarnAt);
1676
- return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw : 80;
1826
+ return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw : 60;
1827
+ })(),
1828
+ scheduledMonthlyCapUsd: (() => {
1829
+ // Zero is a legal answer — "this project may not spend unattended at
1830
+ // all" — so only a negative or unreadable value falls back to the default.
1831
+ const raw = Number(p.scheduledMonthlyCapUsd ?? g.scheduledMonthlyCapUsd);
1832
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_SCHEDULED_MONTHLY_CAP_USD;
1677
1833
  })(),
1678
1834
  };
1679
1835
  }
@@ -1694,17 +1850,77 @@ async function gitInfoCached(folder: string): Promise<GitInfo> {
1694
1850
  return value;
1695
1851
  }
1696
1852
 
1853
+ /**
1854
+ * A mission in a git worktree gets its parent repository without being asked.
1855
+ *
1856
+ * The worktree holds the branch's files; the build config, the shared type
1857
+ * declarations and the parent's node_modules live up in the repository it was
1858
+ * made from. A crew working in a worktree therefore crosses the boundary on
1859
+ * almost every command, and answering the same question all day is not
1860
+ * oversight — it is noise that trains the human to click Allow without
1861
+ * reading. The parent is opened at the start instead, once, in the open: the
1862
+ * transcript records it the same way it records a human's own grant.
1863
+ *
1864
+ * Only the parent. Sibling worktrees stay closed, because one of them may be
1865
+ * another mission's workspace, and so does a parent that a live run is
1866
+ * already working in.
1867
+ */
1868
+ async function grantWorktreeParent(
1869
+ meta: RunMeta, allowed: boolean, emit: ReturnType<typeof makeEmitter>,
1870
+ ): Promise<void> {
1871
+ // Re-derived from scratch every start and every resume, never merely added
1872
+ // to: the setting may have been turned off since, or another mission may
1873
+ // have taken the parent, and a grant that outlives its reason is a hole.
1874
+ // Only Foreman's own grants are withdrawn; a human's stay.
1875
+ const previous = meta.autoRoots ?? [];
1876
+ const shape = allowed ? await worktreeParent(meta.folder).catch(() => null) : null;
1877
+ const busy = activeRuns().filter((r) => r.meta.id !== meta.id).map((r) => r.meta.folder);
1878
+ const decision = worktreeGrant(shape, busy);
1879
+ const now = decision.grant ? [decision.grant] : [];
1880
+ const withdrawn = previous.filter((p) => !now.includes(p));
1881
+
1882
+ if (withdrawn.length || now.some((p) => !previous.includes(p))) {
1883
+ meta.allowedRoots = [...(meta.allowedRoots ?? []).filter((p) => !previous.includes(p)), ...now];
1884
+ meta.autoRoots = now;
1885
+ await store.writeMeta(meta).catch(() => {});
1886
+ }
1887
+ for (const p of withdrawn) {
1888
+ emit('git_note', { text: `Closed ${p} again: ${decision.reason ?? 'Foreman no longer opens it for this mission'}. The crew will ask before it steps outside the mission folder.` });
1889
+ }
1890
+ if (decision.grant && !previous.includes(decision.grant)) {
1891
+ emit('root_allowed', {
1892
+ path: decision.grant, agent: 'foreman',
1893
+ reason: 'this mission runs in a worktree of that repository',
1894
+ });
1895
+ } else if (!decision.grant && decision.reason && !withdrawn.length) {
1896
+ emit('git_note', { text: `Left closed: ${decision.reason}. The crew will ask before it steps outside the mission folder.` });
1897
+ }
1898
+ }
1899
+
1697
1900
  async function startRun(
1698
1901
  projectId: string, folder: string, mission: string, budgetUsd: number,
1699
1902
  directorModel: ModelChoice, workerModel: ModelChoice, browserTools: boolean,
1700
1903
  provider: ProviderRef,
1701
1904
  roleProviders: { director?: string; worker?: string } = {},
1905
+ /**
1906
+ * How this run began. Recorded rather than inferred: an unattended run and
1907
+ * one somebody is watching deserve different treatment later, and the
1908
+ * schedule's monthly ceiling can only count what says it was scheduled.
1909
+ */
1910
+ origin: {
1911
+ startedBy?: 'human' | 'phone' | 'schedule' | 'mcp';
1912
+ scheduleId?: string;
1913
+ scheduleName?: string;
1914
+ } = {},
1702
1915
  ): Promise<void> {
1703
1916
  const settings = await effectiveSettings(projectId);
1917
+ if (origin.scheduleId && origin.scheduleName) scheduleNames.set(origin.scheduleId, origin.scheduleName);
1704
1918
  const meta: RunMeta = {
1705
1919
  id: newRunId(),
1706
1920
  projectId,
1707
1921
  folder, mission, budgetUsd,
1922
+ startedBy: origin.startedBy ?? 'human',
1923
+ ...(origin.scheduleId ? { scheduleId: origin.scheduleId } : {}),
1708
1924
  // An explicit composer choice wins; "Default" inherits from Settings.
1709
1925
  directorModel: directorModel ?? settings.directorModel,
1710
1926
  workerModel: workerModel ?? settings.workerModel,
@@ -1731,6 +1947,23 @@ async function startRun(
1731
1947
  console.error(`failed to create run for project ${projectId}:`, err);
1732
1948
  return;
1733
1949
  }
1950
+ // Whoever reads this transcript later did not start this run, and the first
1951
+ // question they will have is who did. It is the opening line, before the
1952
+ // branch note, so the answer is at the top rather than buried in the meta.
1953
+ if (origin.scheduleId) {
1954
+ const emit = makeEmitter(meta.id, projectId);
1955
+ emit('run_note', {
1956
+ text: `Started by schedule ${origin.scheduleName ?? scheduleNames.get(origin.scheduleId) ?? origin.scheduleId}.`,
1957
+ });
1958
+ }
1959
+ // The schedule's "last run" is this one, recorded the moment it exists
1960
+ // rather than when it ends: a schedule whose mission is still running should
1961
+ // point at it, not at the one before.
1962
+ if (origin.scheduleId) {
1963
+ await store.updateSchedule(origin.scheduleId, {
1964
+ lastRunId: meta.id, lastRunAt: meta.createdAt, lastNote: '',
1965
+ }).catch(() => {});
1966
+ }
1734
1967
  await consumeProposal(projectId, meta.id, mission).catch(() => {});
1735
1968
  // The folder's MISSION.md belongs to whichever run wrote it. Parked into
1736
1969
  // that run's record (when it lacks one) and cleared, so this run's status
@@ -1744,6 +1977,7 @@ async function startRun(
1744
1977
  // touches anything — so the deck's baseline, taken at the director's first
1745
1978
  // turn, is the branch point, and the diff is exactly the mission.
1746
1979
  if (settings.gitBranchPerMission) {
1980
+ const carried = await dirtyPaths(folder);
1747
1981
  const g = await startMissionBranch(folder, mission, meta.id);
1748
1982
  const emit = makeEmitter(meta.id, projectId);
1749
1983
  if ('error' in g) {
@@ -1753,8 +1987,17 @@ async function startRun(
1753
1987
  await store.writeMeta(meta).catch(() => {});
1754
1988
  gitInfoCache.delete(folder);
1755
1989
  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.` });
1990
+ // Whoever started this may not have been at the keyboard to be asked.
1991
+ if (carried.length) {
1992
+ emit('git_note', {
1993
+ text: `The checkout had uncommitted changes when this mission started, and they came along to ${g.branch}: `
1994
+ + `${carried.slice(0, 5).join(', ')}${carried.length > 5 ? `, and ${carried.length - 5} more` : ''}. `
1995
+ + "They will be part of the mission's closing commit unless you take them off the branch first.",
1996
+ });
1997
+ }
1756
1998
  }
1757
1999
  }
2000
+ await grantWorktreeParent(meta, settings.allowWorktreeParent, makeEmitter(meta.id, projectId));
1758
2001
  await driveRun(projectId, meta);
1759
2002
  }
1760
2003
 
@@ -1815,6 +2058,7 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
1815
2058
  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.` });
1816
2059
  gitInfoCache.delete(meta.folder);
1817
2060
  }
2061
+ await grantWorktreeParent(meta, (await effectiveSettings(projectId)).allowWorktreeParent, makeEmitter(meta.id, projectId));
1818
2062
  // The director resumes from MISSION.md. If another mission ran here since,
1819
2063
  // the folder's copy is that mission's; this run's own goes back first.
1820
2064
  const restored = await restoreMissionDoc(store.runDirectory(meta.id), meta.folder).catch(() => 'none' as const);
@@ -1833,6 +2077,153 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
1833
2077
  });
1834
2078
  }
1835
2079
 
2080
+ // ---------------------------------------------------------------------------
2081
+ // Schedules
2082
+ // ---------------------------------------------------------------------------
2083
+
2084
+ /**
2085
+ * Starts a schedule's mission. The project must already be reserved.
2086
+ *
2087
+ * The schedule's own choices win; anything it leaves open falls back to the
2088
+ * project's effective settings, which is what startRun does with an absent
2089
+ * model anyway. The browser judgement is the same heuristic the phone's /run
2090
+ * uses, and for the same reason: there is no box for anyone to tick.
2091
+ */
2092
+ function startScheduledRun(s: Schedule, project: Project, startedBy: 'human' | 'schedule'): void {
2093
+ void startRun(
2094
+ s.projectId, project.folder, s.brief, s.budgetUsd,
2095
+ modelChoice(s.directorModel), modelChoice(s.workerModel),
2096
+ /screenshot|browser|render|console/i.test(s.brief),
2097
+ providerOf(project),
2098
+ { director: s.directorProviderId, worker: s.workerProviderId },
2099
+ { startedBy, scheduleId: s.id, scheduleName: s.name },
2100
+ );
2101
+ }
2102
+
2103
+ /** The next firing of this cadence as a timestamp, or null when there is none. */
2104
+ function nextRunAtMs(cadence: Cadence, after: Date): number | null {
2105
+ const next = nextRunAt(cadence, after);
2106
+ return next ? next.getTime() : null;
2107
+ }
2108
+
2109
+ /**
2110
+ * A scheduled run ended: the schedule remembers how it went.
2111
+ *
2112
+ * Two failures in a row pause it — see afterRunOutcome. Called from the
2113
+ * emitter, which is where the server learns a run finished; the orchestrator
2114
+ * knows nothing about schedules and should not have to.
2115
+ */
2116
+ async function recordScheduleOutcome(scheduleId: string, meta: RunMeta): Promise<void> {
2117
+ const s = await store.getSchedule(scheduleId);
2118
+ if (!s) return;
2119
+ const outcome = afterRunOutcome(s, { status: meta.status, stopReason: meta.stopReason });
2120
+ await store.updateSchedule(s.id, {
2121
+ lastRunId: meta.id,
2122
+ lastRunAt: meta.endedAt ?? Date.now(),
2123
+ lastOutcome: meta.status === 'running' ? undefined : meta.status,
2124
+ // Whatever the last tick could not do, it did this time: the note would
2125
+ // otherwise still read "project busy" beside a run that just finished.
2126
+ lastNote: '',
2127
+ consecutiveFailures: outcome.consecutiveFailures,
2128
+ pausedReason: outcome.pausedReason,
2129
+ });
2130
+ if (outcome.pausedNow) {
2131
+ const emit = scheduleNotice(s.projectId);
2132
+ emit('schedule_paused', { scheduleId: s.id, name: s.name, projectId: s.projectId, reason: 'failures' });
2133
+ }
2134
+ }
2135
+
2136
+ /**
2137
+ * One pass of the ticker: what is due, and what to do about it.
2138
+ *
2139
+ * Passes must not overlap. A pass awaits the store several times, and two of
2140
+ * them interleaving could both see the same schedule as due and start it
2141
+ * twice — the project reservation would catch that, but as a 409 nobody is
2142
+ * there to read rather than as a rule.
2143
+ */
2144
+ let tickInFlight = false;
2145
+ async function scheduleTick(): Promise<void> {
2146
+ if (tickInFlight) return;
2147
+ tickInFlight = true;
2148
+ try {
2149
+ const schedules = await store.listSchedules();
2150
+ if (!schedules.length) return;
2151
+ for (const s of schedules) scheduleNames.set(s.id, s.name);
2152
+ const now = new Date();
2153
+ // A schedule with no next firing would sit dormant for ever — a record
2154
+ // written by an older build, or one whose cadence was never scheduled.
2155
+ // Giving it one here costs a write once and never again.
2156
+ for (const s of schedules) {
2157
+ if (!s.enabled || s.pausedReason !== null || s.nextRunAt !== null) continue;
2158
+ s.nextRunAt = nextRunAtMs(s.cadence, now);
2159
+ await store.updateSchedule(s.id, { nextRunAt: s.nextRunAt }).catch(() => {});
2160
+ }
2161
+ const runs = await store.listRuns().catch(() => [] as RunMeta[]);
2162
+ const monthSpend = new Map<string, number>();
2163
+ const caps = new Map<string, number>();
2164
+ for (const projectId of new Set(schedules.map((s) => s.projectId))) {
2165
+ monthSpend.set(projectId, monthlyScheduledSpend(runs, projectId, now));
2166
+ caps.set(projectId, (await effectiveSettings(projectId)).scheduledMonthlyCapUsd);
2167
+ }
2168
+ const actions = decideTicks({
2169
+ schedules,
2170
+ now,
2171
+ busyProjectIds: new Set(activeByProject.keys()),
2172
+ monthSpend,
2173
+ capFor: (projectId) => caps.get(projectId) ?? DEFAULT_SCHEDULED_MONTHLY_CAP_USD,
2174
+ });
2175
+ for (const action of actions) {
2176
+ const s = schedules.find((x) => x.id === action.scheduleId);
2177
+ if (!s) continue;
2178
+ if (action.kind === 'pause') {
2179
+ await store.updateSchedule(s.id, { pausedReason: action.reason }).catch(() => {});
2180
+ const emit = scheduleNotice(s.projectId);
2181
+ emit('schedule_paused', {
2182
+ scheduleId: s.id, name: s.name, projectId: s.projectId, reason: action.reason,
2183
+ });
2184
+ continue;
2185
+ }
2186
+ const skipped = async (nextAt: number | null) => {
2187
+ await store.updateSchedule(s.id, {
2188
+ nextRunAt: nextAt, lastOutcome: 'skipped', lastNote: 'project busy',
2189
+ }).catch(() => {});
2190
+ const emit = scheduleNotice(s.projectId);
2191
+ emit('schedule_skipped', {
2192
+ scheduleId: s.id, name: s.name, projectId: s.projectId, reason: 'project busy',
2193
+ });
2194
+ };
2195
+ if (action.kind === 'skip') { await skipped(action.nextRunAt); continue; }
2196
+ const project = await store.getProject(s.projectId).catch(() => null);
2197
+ if (!project) {
2198
+ // The project was unlinked and this schedule outlived it. Nothing to
2199
+ // announce; move it along so the tick does not repeat every 30s.
2200
+ await store.updateSchedule(s.id, { nextRunAt: action.nextRunAt }).catch(() => {});
2201
+ continue;
2202
+ }
2203
+ // Reservation is the last step before dispatch — no awaits in between,
2204
+ // exactly as in POST /run. It can still fail: another dispatch may have
2205
+ // taken the project since this pass read the active map, and that is the
2206
+ // skip case, not a tick to drop on the floor.
2207
+ if (!reserveProject(s.projectId)) { await skipped(action.nextRunAt); continue; }
2208
+ startScheduledRun(s, project, 'schedule');
2209
+ await store.updateSchedule(s.id, { nextRunAt: action.nextRunAt }).catch(() => {});
2210
+ }
2211
+ } catch (err) {
2212
+ // A ticker that throws is a ticker that stops. Nothing here is worth the
2213
+ // schedules of every other project.
2214
+ console.error('schedule tick failed:', err);
2215
+ } finally {
2216
+ tickInFlight = false;
2217
+ }
2218
+ }
2219
+
2220
+ // Every half minute, and once shortly after startup so a firing missed while
2221
+ // the machine was off is caught up rather than waiting for the next slot. The
2222
+ // first pass is delayed: the orphan sweep and the store's own startup work
2223
+ // come first, and a schedule is never so urgent that ten seconds matter.
2224
+ setTimeout(() => void scheduleTick(), 10_000).unref();
2225
+ setInterval(() => void scheduleTick(), 30_000).unref();
2226
+
1836
2227
  // ---------------------------------------------------------------------------
1837
2228
  // Static files
1838
2229
  // ---------------------------------------------------------------------------
@@ -1916,6 +2307,52 @@ async function browseRoots(): Promise<string[]> {
1916
2307
  return roots;
1917
2308
  }
1918
2309
 
2310
+ /**
2311
+ * A schedule's writable fields out of a request body, or the first thing wrong
2312
+ * with them. `partial` is the PUT: an absent field means "leave it alone",
2313
+ * where on the POST it means the schedule would be missing something it needs.
2314
+ *
2315
+ * The cadence goes through validateCadence rather than being trusted, because
2316
+ * the failure this guards against is silent — a cron expression nobody can
2317
+ * parse becomes a schedule that simply never fires, and looks healthy doing it.
2318
+ */
2319
+ function parseScheduleBody(
2320
+ b: Record<string, unknown>, partial: boolean,
2321
+ ): { error: string } | { fields: Partial<Schedule> } {
2322
+ const fields: Partial<Schedule> = {};
2323
+ for (const key of ['name', 'brief'] as const) {
2324
+ if (b[key] === undefined) {
2325
+ if (!partial) return { error: `${key} is required` };
2326
+ continue;
2327
+ }
2328
+ const v = typeof b[key] === 'string' ? (b[key] as string).trim() : '';
2329
+ if (!v) return { error: `${key} must not be empty` };
2330
+ fields[key] = v;
2331
+ }
2332
+ if (b.budgetUsd !== undefined) {
2333
+ const n = Number(b.budgetUsd);
2334
+ if (!Number.isFinite(n) || n <= 0) return { error: 'budgetUsd must be a positive number' };
2335
+ fields.budgetUsd = n;
2336
+ } else if (!partial) {
2337
+ return { error: 'budgetUsd is required' };
2338
+ }
2339
+ if (b.cadence !== undefined) {
2340
+ const parsed = validateCadence(b.cadence);
2341
+ if (!parsed.ok) return { error: parsed.error };
2342
+ fields.cadence = parsed.cadence;
2343
+ } else if (!partial) {
2344
+ return { error: 'cadence is required' };
2345
+ }
2346
+ for (const key of ['directorModel', 'workerModel'] as const) {
2347
+ if (typeof b[key] === 'string' && (b[key] as string).trim()) fields[key] = modelChoice(b[key]);
2348
+ }
2349
+ for (const key of ['directorProviderId', 'workerProviderId'] as const) {
2350
+ if (typeof b[key] === 'string' && (b[key] as string).trim()) fields[key] = (b[key] as string).trim();
2351
+ }
2352
+ if (typeof b.enabled === 'boolean') fields.enabled = b.enabled;
2353
+ return { fields };
2354
+ }
2355
+
1919
2356
  function json(res: http.ServerResponse, code: number, body: unknown): void {
1920
2357
  res.writeHead(code, { 'content-type': 'application/json' });
1921
2358
  res.end(JSON.stringify(body));
@@ -1953,8 +2390,13 @@ const server = http.createServer(async (req, res) => {
1953
2390
  const runResumeMatch = url.pathname.match(/^\/runs\/([^/]+)\/resume$/);
1954
2391
  const prMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr$/);
1955
2392
  const prStateMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr\/state$/);
2393
+ const runServicesMatch = url.pathname.match(/^\/runs\/([^/]+)\/services$/);
2394
+ const stopServiceMatch = url.pathname.match(/^\/runs\/([^/]+)\/services\/(\d{1,5})\/stop$/);
1956
2395
  const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
1957
2396
  const providerKeyMatch = url.pathname.match(/^\/providers\/([A-Za-z0-9_-]{1,64})\/key$/);
2397
+ const projectSchedulesMatch = url.pathname.match(/^\/projects\/([^/]+)\/schedules$/);
2398
+ const scheduleMatch = url.pathname.match(/^\/schedules\/([^/]+)$/);
2399
+ const scheduleActionMatch = url.pathname.match(/^\/schedules\/([^/]+)\/(pause|resume|run-now)$/);
1958
2400
 
1959
2401
  try {
1960
2402
  if (req.method === 'GET' && (url.pathname === '/'
@@ -2238,7 +2680,7 @@ const server = http.createServer(async (req, res) => {
2238
2680
  } else if (req.method === 'POST' && url.pathname === '/run') {
2239
2681
  const {
2240
2682
  projectId, mission, budgetUsd, directorModel, workerModel, browserTools,
2241
- directorProviderId, workerProviderId,
2683
+ directorProviderId, workerProviderId, allowDirty, startedBy,
2242
2684
  } = await readBody(req);
2243
2685
  if (typeof projectId !== 'string' || typeof mission !== 'string' || !mission.trim()) {
2244
2686
  return json(res, 400, { error: 'projectId and mission are required' });
@@ -2246,6 +2688,29 @@ const server = http.createServer(async (req, res) => {
2246
2688
  const project = await store.getProject(projectId);
2247
2689
  if (!project) return json(res, 404, { error: 'unknown project' });
2248
2690
  await mkdir(project.folder, { recursive: true });
2691
+ // A mission on its own branch takes the working tree with it: `checkout
2692
+ // -b` carries uncommitted work onto the mission branch, and the closing
2693
+ // commit sweeps whatever is left into the mission's own commit, under
2694
+ // the mission's name. So a dirty checkout is refused here rather than
2695
+ // quietly absorbed — with an override, because it is the human's tree
2696
+ // and their call. Only this route refuses: a mission started from the
2697
+ // phone has nobody standing at the keyboard to answer, and gets the
2698
+ // warning in its transcript instead.
2699
+ {
2700
+ const s = await effectiveSettings(projectId);
2701
+ if (s.gitBranchPerMission && allowDirty !== true) {
2702
+ const info = await gitInfo(project.folder);
2703
+ if (info.repo && info.dirty) {
2704
+ const files = await dirtyPaths(project.folder);
2705
+ return json(res, 409, {
2706
+ error: `This checkout has uncommitted changes on ${info.branch ?? 'HEAD'}. `
2707
+ + 'They would follow the mission onto its branch and be committed with its work. '
2708
+ + 'Commit or stash them first, or start anyway.',
2709
+ code: 'dirty-checkout', branch: info.branch ?? null, files,
2710
+ });
2711
+ }
2712
+ }
2713
+ }
2249
2714
  // Reservation is the last step before dispatch — no awaits in between.
2250
2715
  if (!reserveProject(projectId)) {
2251
2716
  return json(res, 409, { error: 'this project already has an active mission' });
@@ -2257,7 +2722,11 @@ const server = http.createServer(async (req, res) => {
2257
2722
  {
2258
2723
  director: typeof directorProviderId === 'string' ? directorProviderId : undefined,
2259
2724
  worker: typeof workerProviderId === 'string' ? workerProviderId : undefined,
2260
- });
2725
+ },
2726
+ // 'schedule' is deliberately not accepted here: a caller must not be
2727
+ // able to forge a scheduled start and charge the month's unattended
2728
+ // allowance for a run no schedule asked for.
2729
+ { startedBy: startedBy === 'phone' || startedBy === 'mcp' ? startedBy : 'human' });
2261
2730
  json(res, 200, { ok: true });
2262
2731
 
2263
2732
  } else if (url.pathname === '/notify' && req.method === 'GET') {
@@ -2673,6 +3142,33 @@ const server = http.createServer(async (req, res) => {
2673
3142
  broadcastChat(FLEET_CHAT_ID, 'chat_cleared', {});
2674
3143
  json(res, 200, { ok: true });
2675
3144
 
3145
+ } else if (runServicesMatch && req.method === 'GET') {
3146
+ // What this run exposed, and what of it is still up. Asked after a run
3147
+ // ends as much as during it: a finished mission leaves no agent behind
3148
+ // to tidy, so the human needs somewhere to see the leftovers.
3149
+ const meta = await store.readMeta(runServicesMatch[1]).catch(() => null);
3150
+ if (!meta) return json(res, 404, { error: 'unknown run' });
3151
+ const list = await Promise.all((meta.services ?? []).map(async (s) => {
3152
+ const holder = await listeningPid(s.port);
3153
+ return {
3154
+ port: s.port, label: s.label, since: s.since, path: s.path,
3155
+ listening: holder !== null,
3156
+ // Only a process Foreman watched start is one it will offer to stop.
3157
+ ours: holder !== null && s.pid !== undefined && holder === s.pid,
3158
+ };
3159
+ }));
3160
+ json(res, 200, { runId: meta.id, status: meta.status, services: list });
3161
+
3162
+ } else if (stopServiceMatch && req.method === 'POST') {
3163
+ const meta = await store.readMeta(stopServiceMatch[1]).catch(() => null);
3164
+ if (!meta) return json(res, 404, { error: 'unknown run' });
3165
+ const port = Number(stopServiceMatch[2]);
3166
+ const svc = (meta.services ?? []).find((s) => s.port === port);
3167
+ if (!svc) return json(res, 404, { error: 'this run did not expose that port' });
3168
+ const r = await stopService(port, svc.pid);
3169
+ if (!r.ok) return json(res, 409, { error: r.reason });
3170
+ json(res, 200, { ok: true, how: r.how });
3171
+
2676
3172
  } else if (prStateMatch && req.method === 'GET') {
2677
3173
  // What became of the run's pull request. A final answer (merged,
2678
3174
  // closed) is written to the run so it is not asked again; open is.
@@ -2695,9 +3191,13 @@ const server = http.createServer(async (req, res) => {
2695
3191
  if (!info.remote) return json(res, 409, { error: 'the repository has no origin remote to push to' });
2696
3192
  const doc = await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null);
2697
3193
  const gh = await ghReady();
3194
+ // Where the request can actually go, which is not always where the
3195
+ // mission was branched from — see resolvePrBase.
3196
+ const target = await resolvePrBase(meta.folder, meta.git.base);
2698
3197
  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),
3198
+ ...prDraft(meta, doc), branch: meta.git.branch, base: target.base, commits: meta.git.commits ?? null,
3199
+ branchedFrom: meta.git.base, baseFellBack: target.fellBack,
3200
+ remote: info.remote, compareUrl: compareUrl(info.remote, target.base, meta.git.branch),
2701
3201
  gh, pr: meta.git.pr ?? null, onBranch: info.branch === meta.git.branch, dirty: Boolean(info.dirty),
2702
3202
  });
2703
3203
 
@@ -2725,18 +3225,24 @@ const server = http.createServer(async (req, res) => {
2725
3225
  let url: string | undefined;
2726
3226
  let method: 'gh' | 'compare' = 'compare';
2727
3227
  let note: string | undefined;
3228
+ const target = await resolvePrBase(meta.folder, meta.git.base);
2728
3229
  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 });
3230
+ const r = await createPullRequest(meta.folder, { base: target.base, branch: meta.git.branch, title: t, body: b });
2730
3231
  if (r.url) { url = r.url; method = 'gh'; } else note = r.error;
2731
3232
  }
2732
- if (!url) url = compareUrl(info.remote, meta.git.base, meta.git.branch) ?? undefined;
3233
+ if (!url) url = compareUrl(info.remote, target.base, meta.git.branch) ?? undefined;
2733
3234
  meta.git = { ...meta.git, pr: method === 'gh' ? url : meta.git.pr };
2734
3235
  await store.writeMeta(meta).catch(() => {});
2735
3236
  gitInfoCache.delete(meta.folder);
2736
3237
  const text = method === 'gh'
2737
3238
  ? `Pushed ${meta.git.branch} and opened a pull request: ${url}`
2738
3239
  : `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 });
3240
+ emit('pull_request', {
3241
+ branch: meta.git.branch, base: target.base, url, method,
3242
+ text: target.fellBack
3243
+ ? `${text} (targeting ${target.base}: this mission was branched from ${meta.git.base}, which is not on the remote)`
3244
+ : text,
3245
+ });
2740
3246
  json(res, 200, { ok: true, url, method, pushed: true, note });
2741
3247
 
2742
3248
  } else if (req.method === 'POST' && url.pathname === '/steer') {
@@ -2827,6 +3333,107 @@ const server = http.createServer(async (req, res) => {
2827
3333
  if (!project) return json(res, 404, { error: 'unknown project' });
2828
3334
  json(res, 200, await readMemory(project.folder));
2829
3335
 
3336
+ } else if (req.method === 'GET' && url.pathname === '/schedules/preview') {
3337
+ // The picker's "next three runs". Deliberately the same function the
3338
+ // ticker fires from, so what the human is shown and what will actually
3339
+ // happen cannot drift apart.
3340
+ let cadence: unknown;
3341
+ try { cadence = JSON.parse(url.searchParams.get('cadence') ?? ''); }
3342
+ catch { return json(res, 400, { error: 'cadence must be a JSON object in the query string' }); }
3343
+ const parsed = validateCadence(cadence);
3344
+ if (!parsed.ok) return json(res, 400, { error: parsed.error });
3345
+ json(res, 200, { next: nextRuns(parsed.cadence, new Date(), 3).map((d) => d.getTime()) });
3346
+
3347
+ } else if (req.method === 'GET' && projectSchedulesMatch) {
3348
+ const projectId = projectSchedulesMatch[1];
3349
+ if (!await store.getProject(projectId)) return json(res, 404, { error: 'unknown project' });
3350
+ const [schedules, runs, settings] = await Promise.all([
3351
+ store.listSchedules(projectId), store.listRuns().catch(() => [] as RunMeta[]), effectiveSettings(projectId),
3352
+ ]);
3353
+ json(res, 200, {
3354
+ schedules,
3355
+ monthSpendUsd: monthlyScheduledSpend(runs, projectId, new Date()),
3356
+ monthlyCapUsd: settings.scheduledMonthlyCapUsd,
3357
+ });
3358
+
3359
+ } else if (req.method === 'POST' && projectSchedulesMatch) {
3360
+ const projectId = projectSchedulesMatch[1];
3361
+ if (!await store.getProject(projectId)) return json(res, 404, { error: 'unknown project' });
3362
+ const parsed = parseScheduleBody(await readBody(req), false);
3363
+ if ('error' in parsed) return json(res, 400, { error: parsed.error });
3364
+ const f = parsed.fields;
3365
+ const schedule = await store.addSchedule({
3366
+ projectId,
3367
+ name: f.name!, brief: f.brief!, cadence: f.cadence!, budgetUsd: f.budgetUsd!,
3368
+ directorModel: f.directorModel, workerModel: f.workerModel,
3369
+ directorProviderId: f.directorProviderId, workerProviderId: f.workerProviderId,
3370
+ // Enabled unless the caller said otherwise: a schedule nobody switched
3371
+ // on is a form somebody filled in and forgot.
3372
+ enabled: f.enabled !== false,
3373
+ nextRunAt: f.enabled === false ? null : nextRunAtMs(f.cadence!, new Date()),
3374
+ consecutiveFailures: 0,
3375
+ pausedReason: null,
3376
+ });
3377
+ scheduleNames.set(schedule.id, schedule.name);
3378
+ json(res, 200, { schedule });
3379
+
3380
+ } else if (req.method === 'PUT' && scheduleMatch) {
3381
+ const existing = await store.getSchedule(scheduleMatch[1]);
3382
+ if (!existing) return json(res, 404, { error: 'unknown schedule' });
3383
+ const parsed = parseScheduleBody(await readBody(req), true);
3384
+ if ('error' in parsed) return json(res, 400, { error: parsed.error });
3385
+ const f = parsed.fields;
3386
+ const enabled = f.enabled ?? existing.enabled;
3387
+ const patch: Partial<Schedule> = { ...f };
3388
+ // A changed cadence is a changed answer to "when next?", counted from
3389
+ // now: keeping the old firing time would mean the schedule the human
3390
+ // just moved fires one more time on the schedule they moved it off.
3391
+ if (f.cadence || f.enabled !== undefined) {
3392
+ patch.nextRunAt = enabled && existing.pausedReason === null
3393
+ ? nextRunAtMs(f.cadence ?? existing.cadence, new Date())
3394
+ : null;
3395
+ }
3396
+ const schedule = await store.updateSchedule(existing.id, patch);
3397
+ if (schedule) scheduleNames.set(schedule.id, schedule.name);
3398
+ json(res, 200, { schedule });
3399
+
3400
+ } else if (req.method === 'DELETE' && scheduleMatch) {
3401
+ const removed = await store.removeSchedule(scheduleMatch[1]);
3402
+ json(res, removed ? 200 : 404, removed ? { ok: true } : { error: 'unknown schedule' });
3403
+
3404
+ } else if (req.method === 'POST' && scheduleActionMatch) {
3405
+ const [, scheduleId, action] = scheduleActionMatch;
3406
+ const s = await store.getSchedule(scheduleId);
3407
+ if (!s) return json(res, 404, { error: 'unknown schedule' });
3408
+ if (action === 'pause') {
3409
+ json(res, 200, { schedule: await store.updateSchedule(s.id, { pausedReason: 'human', nextRunAt: null }) });
3410
+
3411
+ } else if (action === 'resume') {
3412
+ // Resuming is a dashboard act and has no remote equivalent on purpose:
3413
+ // whatever paused this — the human, the month's ceiling, two failures
3414
+ // in a row — is a thing to look at before it runs unattended again.
3415
+ json(res, 200, {
3416
+ schedule: await store.updateSchedule(s.id, {
3417
+ pausedReason: null,
3418
+ consecutiveFailures: 0,
3419
+ nextRunAt: s.enabled ? nextRunAtMs(s.cadence, new Date()) : null,
3420
+ }),
3421
+ });
3422
+
3423
+ } else {
3424
+ const project = await store.getProject(s.projectId);
3425
+ if (!project) return json(res, 404, { error: 'unknown project' });
3426
+ // Allowed even at the monthly ceiling, and not counted against it
3427
+ // beforehand: the ceiling governs UNATTENDED spending, and a human
3428
+ // pressing a button is by definition not that. The run still carries
3429
+ // the scheduleId, so what it costs does count towards the month.
3430
+ if (!reserveProject(s.projectId)) {
3431
+ return json(res, 409, { error: 'this project already has an active mission' });
3432
+ }
3433
+ startScheduledRun(s, project, 'human');
3434
+ json(res, 200, { ok: true });
3435
+ }
3436
+
2830
3437
  } else if (req.method === 'GET' && url.pathname === '/missiondoc') {
2831
3438
  const runId = url.searchParams.get('run');
2832
3439
  if (!runId) return json(res, 400, { error: 'run parameter is required' });
@@ -2958,7 +3565,7 @@ void closeOrphanedChatTurns().then((ids) => {
2958
3565
  // Services declared by earlier runs are still worth proxying if their
2959
3566
  // processes outlived the run; the registry is rebuilt from what was saved.
2960
3567
  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);
3568
+ 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
3569
  }).catch(() => {});
2963
3570
 
2964
3571
  if (BIND === 'all') {