@amenophis1er/foreman 0.1.4 → 0.1.6

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
@@ -24,6 +24,9 @@
24
24
  * POST /runs/{id}/resume Resume an interrupted/failed run
25
25
  * POST /permission Resolve an approval {id, behavior, message?}
26
26
  * POST /answer Answer a director question {id, text}
27
+ * POST /fleet/chat One turn with the fleet planner {text} → {text, costUsd}
28
+ * POST /fleet/stop Stop the fleet planner reply in flight
29
+ * DELETE /fleet/chat Forget the fleet conversation
27
30
  * POST /steer Send an operator note to a running director {runId, text}
28
31
  * POST /interrupt Interrupt a run {runId}
29
32
  * GET /runs?projectId= Persisted run summaries, newest first
@@ -57,7 +60,11 @@ import { saveAttachments } from './attachments.js';
57
60
  import { detectTailscale, tailnetUrl } from './tailscale.js';
58
61
  import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
59
62
  import { ServiceRegistry, SVC_PREFIX, parseServicePath, portOpen, proxyToService, servicePath } from './services.js';
60
- import { HELP_TEXT, parseCommand, projectsRoot, slug } from './notify/commands.js';
63
+ import { HELP_TEXT, expandHome, parseCommand, projectsRoot, slug } from './notify/commands.js';
64
+ import {
65
+ DEFAULT_FLEET_MODEL, FLEET_CHAT_ID, PHONE_CONTEXT_MS, phoneRoute, runFleetTurn,
66
+ type FleetHost, type FleetProjectView,
67
+ } from './fleet-planner.js';
61
68
  import { escapeHtml as escTg } from './notify.js';
62
69
  import { RunStore, newRunId } from './store.js';
63
70
  import { preflight, reportPreflight } from './preflight.js';
@@ -84,7 +91,7 @@ import {
84
91
  } from './preflight.js';
85
92
  import { combineBasis, costBasisOf } from './types.js';
86
93
  import type {
87
- ChatMeta, CostBasis, ForemanEvent, ModelChoice, Project, ProviderRef, RunMeta, ToolPolicy,
94
+ ChatMeta, CostBasis, ForemanEvent, MissionProposal, ModelChoice, Project, ProviderRef, RunMeta, ToolPolicy,
88
95
  } from './types.js';
89
96
 
90
97
  /**
@@ -419,7 +426,7 @@ const PORT = Number(process.env.PORT ?? 4177);
419
426
  * `FOREMAN_BIND=local` keeps it to this machine even with Tailscale up.
420
427
  */
421
428
  const BIND = (process.env.FOREMAN_BIND ?? 'auto') as 'auto' | 'all' | 'local';
422
- const tailnet = BIND === 'local' ? null : await detectTailscale();
429
+ const tailnet = BIND === 'local' ? null : await detectTailscale(PORT);
423
430
  /** Dev servers the crew put behind /svc/ — see services.ts. */
424
431
  const services = new ServiceRegistry();
425
432
  /**
@@ -450,6 +457,49 @@ function activeRuns(): MissionRun[] {
450
457
  }
451
458
 
452
459
  /** Broadcasts an enveloped frame to live clients and persists the bare event. */
460
+ /**
461
+ * What happened in the fleet lately, in one line each, for the front desk.
462
+ *
463
+ * In memory only: it exists so the fleet planner can open with the news
464
+ * instead of asking, and the news is by definition recent. A restart
465
+ * empties it and says so. Two hundred lines outlast any plausible gap
466
+ * between two phone messages.
467
+ */
468
+ const SERVER_STARTED_AT = Date.now();
469
+ const fleetLog: Array<{ ts: number; projectId: string; text: string }> = [];
470
+ function noteFleetEvent(projectId: string, event: string, d: Record<string, unknown>): void {
471
+ const short = (v: unknown, n = 90): string => { const t = String(v ?? '').split('\n')[0].trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
472
+ let text: string | null = null;
473
+ switch (event) {
474
+ case 'run_started': text = `mission started: "${short(d.mission)}"`; break;
475
+ case 'run_resumed': text = 'mission resumed'; break;
476
+ case 'run_finished': text = `mission ended: ${d.status}${typeof d.costUsd === 'number' ? ` ($${(d.costUsd as number).toFixed(2)})` : ''}`; break;
477
+ case 'run_error': text = `run error: ${short(d.error, 120)}`; break;
478
+ case 'mission_incomplete': text = `ended with boxes unticked: ${short(d.text, 120)}`; break;
479
+ case 'permission_request': text = `${d.agent ?? 'the crew'} asked to use ${d.toolName ?? d.tool ?? 'a tool'} — waiting on the human`; break;
480
+ case 'question': text = `${d.agent ?? 'the director'} asked the human: "${short(d.question)}"`; break;
481
+ case 'worker_stalled': text = `a worker stalled: ${short(d.text, 100)}`; break;
482
+ case 'service_exposed': text = `service up: ${short(d.label)} at ${d.url ?? ''}`; break;
483
+ case 'mission_proposed': text = `a mission was proposed (cap $${d.budgetUsd}): "${short(d.mission)}"`; break;
484
+ case 'mission_started': text = 'the proposal was started as a mission'; break;
485
+ case 'chat_proposal_dismissed': text = 'the proposal was discarded'; break;
486
+ }
487
+ if (!text) return;
488
+ fleetLog.push({ ts: Date.now(), projectId, text });
489
+ if (fleetLog.length > 200) fleetLog.splice(0, fleetLog.length - 200);
490
+ }
491
+
492
+ /** The news since `since`, as lines for the front desk's prompt. */
493
+ function fleetNews(since: number, max = 12): string[] {
494
+ const hhmm = (t: number) => new Date(t).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
495
+ const lines = fleetLog.filter((e) => e.ts > since && e.projectId !== FLEET_CHAT_ID)
496
+ .map((e) => `${hhmm(e.ts)} ${projectsCache.get(e.projectId)?.name ?? e.projectId} — ${e.text}`);
497
+ const out = lines.slice(-max);
498
+ if (lines.length > max) out.unshift(`(${lines.length - max} earlier lines not shown)`);
499
+ if (SERVER_STARTED_AT > since) out.unshift(`Foreman restarted at ${hhmm(SERVER_STARTED_AT)}; anything before that is not listed here (the tools still know).`);
500
+ return out;
501
+ }
502
+
453
503
  function makeEmitter(runId: string, projectId: string) {
454
504
  return (event: string, data: unknown): void => {
455
505
  const evt: ForemanEvent = { ts: Date.now(), event, data };
@@ -457,6 +507,7 @@ function makeEmitter(runId: string, projectId: string) {
457
507
  `event: ${event}\ndata: ${JSON.stringify({ runId, projectId, data })}\n\n`;
458
508
  for (const res of sseClients) res.write(frame);
459
509
  void store.append(runId, evt);
510
+ noteFleetEvent(projectId, event, (data ?? {}) as Record<string, unknown>);
460
511
  // The one place notifications hang off the mission stream. Labels are
461
512
  // cached here from the events themselves so a message can name the run
462
513
  // without a disk read on the emitter's path.
@@ -520,6 +571,7 @@ function ledgerKeyFor(meta: RunMeta): string {
520
571
  function broadcastChat(projectId: string, event: string, data: unknown): void {
521
572
  const frame = `event: ${event}\ndata: ${JSON.stringify({ runId: null, projectId, chat: true, data })}\n\n`;
522
573
  for (const res of sseClients) res.write(frame);
574
+ noteFleetEvent(projectId, event, (data ?? {}) as Record<string, unknown>);
523
575
  }
524
576
 
525
577
  function makeChatEmitter(projectId: string) {
@@ -529,6 +581,7 @@ function makeChatEmitter(projectId: string) {
529
581
  `event: ${event}\ndata: ${JSON.stringify({ runId: null, projectId, chat: true, data })}\n\n`;
530
582
  for (const res of sseClients) res.write(frame);
531
583
  void store.appendChat(projectId, evt);
584
+ noteFleetEvent(projectId, event, (data ?? {}) as Record<string, unknown>);
532
585
  if (!projectsCache.has(projectId)) {
533
586
  void store.getProject(projectId).then((p) => { if (p) projectsCache.set(projectId, { name: p.name }); });
534
587
  }
@@ -682,10 +735,280 @@ async function findProject(ref: string): Promise<Project | null> {
682
735
  ?? null;
683
736
  }
684
737
 
738
+ // ---------------------------------------------------------------------------
739
+ // The fleet planner — the front desk
740
+ // ---------------------------------------------------------------------------
741
+
742
+ /** When the phone last spoke to a project planner; decides where plain text goes. */
743
+ let lastPhonePlanningAt = 0;
744
+ /** The fleet turn in flight, if any. One at a time: it is one session. */
745
+ let fleetAbort: AbortController | null = null;
746
+
747
+ const firstLine = (s: string): string => s.split('\n').find((l) => l.trim())?.trim().slice(0, 100) ?? '';
748
+ const runTitle = (m: RunMeta): string => m.title || firstLine(m.mission) || m.id;
749
+ const spendLine = (m: RunMeta): string =>
750
+ m.costBasis === 'priced' || m.costBasis === undefined ? `$${m.costUsd.toFixed(2)} of $${m.budgetUsd}` : `${m.costBasis} · cap $${m.budgetUsd}`;
751
+ const clipText = (s: string, n: number): string => (s.length <= n ? s : `${s.slice(0, n).trimEnd()} […]`);
752
+
753
+ /** The director's last words in a run's log: its latest text block, and its result if it ended. */
754
+ async function directorWords(runId: string): Promise<{ last?: string; result?: string; error?: string }> {
755
+ const events = await store.readEvents(runId).catch(() => []);
756
+ let last: string | undefined;
757
+ let result: string | undefined;
758
+ let error: string | undefined;
759
+ for (const e of events) {
760
+ if (e.event === 'run_error' || e.event === 'mission_incomplete') {
761
+ const d = e.data as { error?: unknown; text?: unknown } | undefined;
762
+ const t = d?.error ?? d?.text;
763
+ if (typeof t === 'string' && t.trim()) error = t.trim();
764
+ continue;
765
+ }
766
+ if (e.event !== 'message') continue;
767
+ const d = e.data as { agent?: string; msg?: { type?: string; result?: unknown; message?: { content?: Array<{ type?: string; text?: string }> } } } | undefined;
768
+ if (d?.agent !== 'director' || !d.msg) continue;
769
+ if (d.msg.type === 'assistant') {
770
+ for (const b of d.msg.message?.content ?? []) if (b.type === 'text' && b.text?.trim()) last = b.text.trim();
771
+ } else if (d.msg.type === 'result' && typeof d.msg.result === 'string') {
772
+ result = d.msg.result;
773
+ }
774
+ }
775
+ return { last, result, error };
776
+ }
777
+
778
+ /** DONE WHEN progress as the mission doc records it. */
779
+ async function boxCount(folder: string): Promise<string> {
780
+ const doc = await readFile(path.join(folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => '');
781
+ const ticked = (doc.match(/^\s*[-*] \[[xX]\]/gm) ?? []).length;
782
+ const open = (doc.match(/^\s*[-*] \[ \]/gm) ?? []).length;
783
+ return ticked + open ? `${ticked} of ${ticked + open} boxes ticked` : 'no checklist yet';
784
+ }
785
+
786
+ async function lastRunOf(project: Project): Promise<RunMeta | null> {
787
+ const runs = await store.listRuns().catch(() => [] as RunMeta[]);
788
+ return runs.filter((r) => r.folder === project.folder && r.status !== 'running')
789
+ .sort((a, b) => b.createdAt - a.createdAt)[0] ?? null;
790
+ }
791
+
792
+ async function noSuchProject(ref: string): Promise<string> {
793
+ const names = (await store.listProjects()).map((p) => p.name);
794
+ return `No project called "${ref}". Linked projects: ${names.length ? names.join(', ') : 'none yet'}.`;
795
+ }
796
+
797
+ /**
798
+ * The fleet's verbs, as the front desk may use them. Every method answers in
799
+ * words; nothing here starts, stops or resumes a run.
800
+ */
801
+ const fleetHost: FleetHost = {
802
+ async listProjects() {
803
+ const all = await store.listProjects();
804
+ const runs = await store.listRuns().catch(() => [] as RunMeta[]);
805
+ const out: FleetProjectView[] = [];
806
+ for (const p of all) {
807
+ const live = activeByProject.get(p.id);
808
+ const last = runs.filter((r) => r.folder === p.folder && r.status !== 'running').sort((a, b) => b.createdAt - a.createdAt)[0];
809
+ const meta = await store.readChatMeta(p.id).catch(() => null);
810
+ out.push({
811
+ id: p.id, name: p.name, folder: p.folder,
812
+ running: live ? {
813
+ title: runTitle(live.meta), spend: spendLine(live.meta), startedAt: live.meta.createdAt,
814
+ waiting: live.pendingAsks().map((a) => `${a.kind === 'permission' ? 'approval' : 'question'}: ${clipText(a.text, 120)}`),
815
+ } : undefined,
816
+ lastRun: last ? { title: runTitle(last), status: last.status, endedAt: last.endedAt } : undefined,
817
+ proposalWaiting: Boolean(meta?.proposal),
818
+ plannerReplying: chatTurns.has(p.id),
819
+ });
820
+ }
821
+ return out;
822
+ },
823
+
824
+ async projectDetail(ref) {
825
+ const project = await findProject(ref);
826
+ if (!project) return noSuchProject(ref);
827
+ const live = activeByProject.get(project.id);
828
+ const lines = [`${project.name} — ${project.folder}`];
829
+ if (live) {
830
+ const m = live.meta;
831
+ lines.push(`RUNNING "${runTitle(m)}" · ${spendLine(m)} · ${Math.round((Date.now() - m.createdAt) / 60_000)} min so far`);
832
+ lines.push(await boxCount(m.folder));
833
+ if (m.workers.length) {
834
+ lines.push(`crew: ${m.workers.map((w) => `${w.id} ${w.status} (${clipText(firstLine(w.task), 60)})`).join('; ')}`);
835
+ }
836
+ const asks = live.pendingAsks();
837
+ if (asks.length) {
838
+ lines.push(`WAITING ON THE HUMAN (answered only through the card's buttons, never by you):`);
839
+ for (const a of asks) lines.push(` - ${a.kind}${a.toolName ? ` ${a.toolName}` : ''}: ${clipText(a.text, 200)}${a.options?.length ? ` [options: ${a.options.join(' / ')}]` : ''}`);
840
+ }
841
+ const words = await directorWords(m.id);
842
+ if (words.last) lines.push(`director's latest words: ${clipText(words.last, 600)}`);
843
+ } else {
844
+ const last = await lastRunOf(project);
845
+ lines.push(last
846
+ ? `idle · last run "${runTitle(last)}" ${last.status}${last.endedAt ? ` at ${new Date(last.endedAt).toLocaleString()}` : ''} · ${spendLine(last)}`
847
+ : 'idle · no runs yet');
848
+ if (last && last.status !== 'done') {
849
+ const words = await directorWords(last.id);
850
+ if (words.error) lines.push(`it stopped with: ${clipText(words.error, 300)}`);
851
+ }
852
+ }
853
+ const meta = await store.readChatMeta(project.id).catch(() => null);
854
+ if (meta?.proposal) lines.push(`a proposal is waiting for Start or Discard: "${clipText(firstLine(meta.proposal.mission), 100)}" cap $${meta.proposal.budgetUsd}`);
855
+ if (chatTurns.has(project.id)) lines.push('its planner is replying right now');
856
+ return lines.join('\n');
857
+ },
858
+
859
+ async runReport(ref) {
860
+ const project = await findProject(ref);
861
+ if (!project) return noSuchProject(ref);
862
+ const last = await lastRunOf(project);
863
+ if (!last) return `${project.name} has no finished run yet.`;
864
+ const words = await directorWords(last.id);
865
+ const lines = [
866
+ `${project.name} · "${runTitle(last)}" · ${last.status}${last.endedAt ? ` at ${new Date(last.endedAt).toLocaleString()}` : ''} · ${spendLine(last)}`,
867
+ await boxCount(last.folder),
868
+ ];
869
+ if (words.error) lines.push(`it stopped with: ${clipText(words.error, 400)}`);
870
+ if (last.workers.length) lines.push(`crew: ${last.workers.map((w) => `${w.id} ${w.status}`).join(', ')}`);
871
+ if (words.result) lines.push(`director's closing report:\n${clipText(words.result, 2500)}`);
872
+ else if (words.last) lines.push(`director's last words:\n${clipText(words.last, 2500)}`);
873
+ return lines.join('\n');
874
+ },
875
+
876
+ async createProject(name) {
877
+ const settings = await store.readSettings().catch(() => ({ global: {}, projects: {} }));
878
+ const root = projectsRoot((settings.global as Record<string, unknown>).projectsRoot);
879
+ const dir = slug(name);
880
+ if (!dir) return 'That name leaves nothing to call a folder. Try letters and digits.';
881
+ const existing = await findProject(dir);
882
+ if (existing) return `${existing.name} is already linked at ${existing.folder}.`;
883
+ const folder = path.join(root, dir);
884
+ await mkdir(folder, { recursive: true });
885
+ const project = await store.addProject(folder, name.trim());
886
+ projectsCache.set(project.id, { name: project.name });
887
+ return `Created ${project.name} at ${folder} and linked it. It is empty.`;
888
+ },
889
+
890
+ async linkProject(folderIn) {
891
+ const folder = expandHome(folderIn.trim());
892
+ if (!path.isAbsolute(folder)) return `A folder to link must be an absolute path (or ~/…), not "${folderIn}".`;
893
+ const st = await stat(folder).catch(() => null);
894
+ if (!st?.isDirectory()) return `${folder} is not a folder that exists. create_project makes a new one under the projects root.`;
895
+ const all = await store.listProjects();
896
+ const dup = all.find((p) => p.folder === folder);
897
+ if (dup) return `${dup.name} is already linked at ${folder}.`;
898
+ const project = await store.addProject(folder);
899
+ projectsCache.set(project.id, { name: project.name });
900
+ return `Linked ${project.name} at ${folder}.`;
901
+ },
902
+
903
+ async openPlanning(ref, message) {
904
+ const project = await findProject(ref);
905
+ if (!project) return noSuchProject(ref);
906
+ if (activeByProject.has(project.id)) return `${project.name} has a mission running — planning waits for it to end. steer can pass the director a note now.`;
907
+ if (chatTurns.has(project.id)) return `${project.name}'s planner is still replying to an earlier message.`;
908
+ chatTurns.add(project.id);
909
+ void driveChatTurn(project, message, message, 'telegram');
910
+ return `Handed to ${project.name}'s planner; its reply follows. Plain messages now go to it. Tell the human that in one line and stop.`;
911
+ },
912
+
913
+ async proposeMission(ref, p) {
914
+ const project = await findProject(ref);
915
+ if (!project) return noSuchProject(ref);
916
+ if (activeByProject.has(project.id)) return `${project.name} has a mission running; one active mission per project.`;
917
+ const meta = await chatMetaOf(project.id);
918
+ const proposal: MissionProposal = { ...p, id: `mp-${Date.now().toString(36)}`, createdAt: Date.now() };
919
+ await store.writeChatMeta({ ...meta, proposal, updatedAt: Date.now() });
920
+ const emit = makeChatEmitter(project.id);
921
+ emit('chat_message', { text: `(from the fleet planner) Proposed: ${firstLine(p.mission)}`, via: 'telegram' });
922
+ emit('mission_proposed', { ...proposal, via: 'telegram' });
923
+ return `Proposal card shown for ${project.name} (cap $${p.budgetUsd}${p.browser ? ', browser on' : ''}), on the phone and on the desk, with Start and Discard. Say in one line what you proposed; do not repeat the brief.`;
924
+ },
925
+
926
+ async steer(ref, note) {
927
+ const project = await findProject(ref);
928
+ if (!project) return noSuchProject(ref);
929
+ const run = activeByProject.get(project.id);
930
+ if (!run) return `${project.name} has no mission running, so there is no director to steer.`;
931
+ return run.steer(note) ? `Note passed to ${project.name}'s director; it reads it at its next turn.` : `${project.name}'s director is no longer accepting notes (the run is ending).`;
932
+ },
933
+ };
934
+
935
+ /**
936
+ * One turn at the front desk. `via` says who asked: the phone hears the
937
+ * answer, an HTTP caller gets it back. Same session either way.
938
+ */
939
+ async function driveFleetTurn(text: string, via: 'telegram' | 'http'): Promise<{ text: string; costUsd: number; handedOff?: string; error?: string; busy?: boolean }> {
940
+ if (fleetAbort) return { text: '', costUsd: 0, busy: true };
941
+ const emit = makeChatEmitter(FLEET_CHAT_ID);
942
+ const meta = await chatMetaOf(FLEET_CHAT_ID);
943
+ const g = (await store.readSettings().catch(() => ({ global: {}, projects: {} }))).global as Record<string, unknown>;
944
+ const model = modelChoice(g.fleetPlannerModel ?? g.plannerModel) || DEFAULT_FLEET_MODEL;
945
+ const abort = new AbortController();
946
+ fleetAbort = abort;
947
+ emit('chat_message', { text, via });
948
+ const stopBusy = via === 'telegram' ? notifyHub.busy() : () => {};
949
+ try {
950
+ const resolved = await resolveProvider(providerOf({}), store.root);
951
+ emit('chat_turn', { state: 'thinking', model, provider: resolved.label, costBasis: resolved.costBasis });
952
+ const problem = providerProblem(resolved);
953
+ if (problem) {
954
+ emit('chat_error', { error: `provider unavailable — ${problem}` });
955
+ return { text: '', costUsd: 0, error: `provider unavailable — ${problem}` };
956
+ }
957
+ const cwd = projectsRoot(g.projectsRoot);
958
+ await mkdir(cwd, { recursive: true }).catch(() => {});
959
+ const { models } = await availableModels(null).catch(() => ({ models: [] }));
960
+ // The first turn ever looks back two hours; every later one looks back
961
+ // to the end of the previous turn.
962
+ const since = meta.sessionId ? meta.updatedAt : Date.now() - 2 * 3_600_000;
963
+ const result = await runFleetTurn({
964
+ sessionId: meta.sessionId, text, model, cwd, host: fleetHost, via,
965
+ news: fleetNews(since), sinceMs: Date.now() - since,
966
+ models: models.map((m) => ({ id: m.id, label: m.label, providerId: m.providerId, providerLabel: m.providerLabel, costBasis: m.costBasis, note: m.note })),
967
+ agentEnv: await agentEnvFor(resolved, `chat:${FLEET_CHAT_ID}`),
968
+ emit, abort,
969
+ });
970
+ const next: ChatMeta = { ...meta, sessionId: result.sessionId ?? meta.sessionId, costUsd: meta.costUsd + result.costUsd, updatedAt: Date.now() };
971
+ await store.writeChatMeta(next);
972
+ emit('chat_cost', { costUsd: next.costUsd, turnUsd: result.costUsd });
973
+ if (result.stopped) emit('chat_error', { error: 'Stopped — the rest of this reply was discarded.' });
974
+ else if (result.error) emit('chat_error', { error: result.error });
975
+ if (via === 'telegram') {
976
+ if (result.error) void notifyHub.say(`The fleet planner hit an error: ${escTg(result.error)}`);
977
+ else if (result.said) void notifyHub.say(escTg(result.said.slice(0, 3500)));
978
+ }
979
+ return { text: result.said, costUsd: result.costUsd, handedOff: result.handedOff, error: result.error };
980
+ } catch (err) {
981
+ console.error('fleet planning turn failed:', err);
982
+ emit('chat_error', { error: String(err) });
983
+ return { text: '', costUsd: 0, error: String(err) };
984
+ } finally {
985
+ stopBusy();
986
+ if (fleetAbort === abort) fleetAbort = null;
987
+ emit('chat_turn', { state: 'idle' });
988
+ }
989
+ }
990
+
991
+ /** Plain text from the phone: the project planner you were just in, else the front desk. */
992
+ async function routePhoneTalk(text: string): Promise<void> {
993
+ const say = (t: string) => void notifyHub.say(t);
994
+ const last = lastPhonePlanning ? { projectId: lastPhonePlanning, at: lastPhonePlanningAt } : null;
995
+ if (phoneRoute(last, Date.now(), PHONE_CONTEXT_MS) === 'project' && last) {
996
+ const project = await store.getProject(last.projectId);
997
+ if (project && !activeByProject.has(project.id)) {
998
+ if (chatTurns.has(project.id)) return say('The planner is still replying — wait, or /stop.');
999
+ chatTurns.add(project.id);
1000
+ void driveChatTurn(project, text, text, 'telegram');
1001
+ return;
1002
+ }
1003
+ }
1004
+ const r = await driveFleetTurn(text, 'telegram');
1005
+ if (r.busy) say('The fleet planner is still replying — wait, or /stop.');
1006
+ }
1007
+
685
1008
  /**
686
1009
  * Text from the linked chat. In order: a command; an answer to an open ask
687
1010
  * (the hub's job); a continuation of the last planning conversation the
688
- * phone started; else the help text — never silence.
1011
+ * phone started; else the fleet planner — never silence.
689
1012
  */
690
1013
  async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
691
1014
  const say = (t: string) => void notifyHub.say(t);
@@ -694,6 +1017,17 @@ async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
694
1017
  try {
695
1018
  switch (cmd.cmd) {
696
1019
  case 'help': return say(HELP_TEXT);
1020
+ case 'fleet': {
1021
+ // Back to the front desk, with or without something to say. The
1022
+ // project context is dropped either way: that is what the command
1023
+ // is for.
1024
+ lastPhonePlanning = null;
1025
+ lastPhonePlanningAt = 0;
1026
+ if (!cmd.text) return say('Front desk. Ask me anything about the fleet, or say what you want started where.');
1027
+ const r = await driveFleetTurn(cmd.text, 'telegram');
1028
+ if (r.busy) say('The fleet planner is still replying — wait, or /stop.');
1029
+ return;
1030
+ }
697
1031
  case 'projects': {
698
1032
  const all = await store.listProjects();
699
1033
  if (!all.length) return say('No projects linked yet. /new &lt;name&gt; creates one.');
@@ -717,10 +1051,10 @@ async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
717
1051
  for (const id of await store.listChatIds()) {
718
1052
  if (chatTurns.has(id)) continue;
719
1053
  const m = await store.readChatMeta(id).catch(() => null);
720
- if (m?.proposal) {
721
- const name = projectsCache.get(id)?.name ?? (await store.getProject(id))?.name ?? id;
722
- planning.push(`• <b>${escTg(name)}</b> a proposal is waiting for Start or Discard`);
723
- }
1054
+ if (!m?.proposal) continue;
1055
+ // A conversation left behind by an unlinked project is not planning.
1056
+ const name = projectsCache.get(id)?.name ?? (await store.getProject(id))?.name;
1057
+ if (name) planning.push(`• <b>${escTg(name)}</b> — a proposal is waiting for Start or Discard`);
724
1058
  }
725
1059
  if (!live.length && !planning.length) return say('All quiet — nothing running, nothing waiting on you.');
726
1060
  if (!live.length) return say(`<b>Planning</b>\n${planning.join('\n')}`);
@@ -766,10 +1100,13 @@ async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
766
1100
  case 'stop': {
767
1101
  const project = cmd.project ? await findProject(cmd.project) : (lastPhonePlanning ? await store.getProject(lastPhonePlanning) : null);
768
1102
  const abort = project && chatAborts.get(project.id);
769
- if (!project || !abort) return say('No planner reply is in flight.');
770
- dropPendingAsk(project.id);
771
- abort.abort();
772
- return say(`Stopped the planner on <b>${escTg(project.name)}</b>.`);
1103
+ if (project && abort) {
1104
+ dropPendingAsk(project.id);
1105
+ abort.abort();
1106
+ return say(`Stopped the planner on <b>${escTg(project.name)}</b>.`);
1107
+ }
1108
+ if (fleetAbort) { fleetAbort.abort(); return say('Stopped the fleet planner.'); }
1109
+ return say('No planner reply is in flight.');
773
1110
  }
774
1111
  }
775
1112
  } catch (err) {
@@ -777,16 +1114,7 @@ async function handlePhoneText(text: string, replyTo?: string): Promise<void> {
777
1114
  }
778
1115
  }
779
1116
  if (notifyHub.handleText(text, replyTo)) return;
780
- if (lastPhonePlanning) {
781
- const project = await store.getProject(lastPhonePlanning);
782
- if (project && !activeByProject.has(project.id)) {
783
- if (chatTurns.has(project.id)) return say('The planner is still replying — wait, or /stop.');
784
- chatTurns.add(project.id);
785
- void driveChatTurn(project, text, text, 'telegram');
786
- return;
787
- }
788
- }
789
- say(`Nothing is waiting on an answer. ${HELP_TEXT}`);
1117
+ await routePhoneTalk(text);
790
1118
  }
791
1119
 
792
1120
  /** One linking attempt at a time; a new code cancels the previous wait. */
@@ -835,9 +1163,10 @@ async function driveChatTurn(project: Project, text: string, shown: string = tex
835
1163
  if (event === 'mission_proposed') { asked = true; data = { ...(data as object), via }; }
836
1164
  raw(event, data);
837
1165
  };
838
- if (via === 'telegram') lastPhonePlanning = project.id;
1166
+ if (via === 'telegram') { lastPhonePlanning = project.id; lastPhonePlanningAt = Date.now(); }
839
1167
  const meta = await chatMetaOf(project.id);
840
1168
  emit('chat_message', { text: shown, ...(via ? { via } : {}) });
1169
+ const stopBusy = via === 'telegram' ? notifyHub.busy() : () => {};
841
1170
  try {
842
1171
  const settings = await effectiveSettings(project.id);
843
1172
  const resolved = await resolveProvider(providerOf(project), store.root);
@@ -903,9 +1232,13 @@ async function driveChatTurn(project: Project, text: string, shown: string = tex
903
1232
  console.error(`planning turn failed for project ${project.id}:`, err);
904
1233
  emit('chat_error', { error: String(err) });
905
1234
  } finally {
1235
+ stopBusy();
906
1236
  chatTurns.delete(project.id);
907
1237
  chatAborts.delete(project.id);
908
1238
  emit('chat_turn', { state: 'idle' });
1239
+ // The reply is the latest word in the conversation, so the phone's
1240
+ // context window counts from it, not from the message that started it.
1241
+ if (via === 'telegram' && lastPhonePlanning === project.id) lastPhonePlanningAt = Date.now();
909
1242
  }
910
1243
  }
911
1244
 
@@ -1155,30 +1488,26 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
1155
1488
  directorModel?: string; directorProviderId?: string; workerModel?: string; workerProviderId?: string;
1156
1489
  } = {}): Promise<void> {
1157
1490
  const sessionId = meta.directorSessionId;
1158
- // Resume re-reads Settings, so changing models or tool policy after a
1159
- // failure takes effect on the retry. A director session cannot switch
1160
- // model mid-session, so a changed director model restarts the session
1161
- // fresh (the mission doc carries the state forward).
1162
- const base = await effectiveSettings(projectId);
1163
- const settings = {
1164
- ...base,
1165
- ...(pick.directorModel ? { directorModel: modelChoice(pick.directorModel), directorProviderId: pick.directorProviderId } : {}),
1166
- ...(pick.workerModel ? { workerModel: modelChoice(pick.workerModel), workerProviderId: pick.workerProviderId } : {}),
1167
- };
1168
- // A model is picked together with the provider that serves it, so a
1169
- // change of either moves the role. Without the provider following the
1170
- // model, "resume on Sonnet" after a Codex usage limit went back through
1171
- // the Codex gateway which remapped the unknown id to its own default and
1172
- // hit the same 429. The provider id is left undefined when Settings does
1173
- // not pin one, which means the project's own provider, as at start.
1174
- const directorChanged =
1175
- (settings.directorModel !== undefined && settings.directorModel !== meta.directorModel)
1176
- || (settings.directorModel !== undefined && settings.directorProviderId !== meta.directorProviderId);
1177
- const workerChanged =
1178
- (settings.workerModel !== undefined && settings.workerModel !== meta.workerModel)
1179
- || (settings.workerModel !== undefined && settings.workerProviderId !== meta.workerProviderId);
1180
- if (directorChanged) { meta.directorModel = settings.directorModel; meta.directorProviderId = settings.directorProviderId; }
1181
- if (workerChanged) { meta.workerModel = settings.workerModel; meta.workerProviderId = settings.workerProviderId; }
1491
+ // Resume re-reads Settings for tool policy and auto-allow, so a policy
1492
+ // change after a failure takes effect on the retry. Models are the run's
1493
+ // own unless "Resume on…" says otherwise; a changed director model then
1494
+ // restarts the session fresh (the mission doc carries the state forward).
1495
+ const settings = await effectiveSettings(projectId);
1496
+ // A plain Resume keeps the run's own models. It used to re-read the
1497
+ // project's Settings and treat any difference as "the human changed the
1498
+ // model" but a run whose models were chosen at start (a local model
1499
+ // picked on the card) differs from Settings by construction, and one
1500
+ // Resume silently handed a 9B local-model test to Fable and Opus, at $4.82,
1501
+ // and called the result the 9B's. Changing models on resume is now only
1502
+ // ever explicit: "Resume on…" passes `pick`. A model is picked together
1503
+ // with the provider that serves it; a pick without a provider id means the
1504
+ // project's own provider, as at start.
1505
+ const directorChanged = Boolean(pick.directorModel) && (
1506
+ modelChoice(pick.directorModel) !== meta.directorModel || pick.directorProviderId !== meta.directorProviderId);
1507
+ const workerChanged = Boolean(pick.workerModel) && (
1508
+ modelChoice(pick.workerModel) !== meta.workerModel || pick.workerProviderId !== meta.workerProviderId);
1509
+ if (directorChanged) { meta.directorModel = modelChoice(pick.directorModel); meta.directorProviderId = pick.directorProviderId; }
1510
+ if (workerChanged) { meta.workerModel = modelChoice(pick.workerModel); meta.workerProviderId = pick.workerProviderId; }
1182
1511
  meta.toolPolicy = settings.toolPolicy;
1183
1512
  meta.autoAllowReadOnly = settings.autoAllowReadOnly;
1184
1513
  meta.status = 'running';
@@ -1498,6 +1827,9 @@ const server = http.createServer(async (req, res) => {
1498
1827
  return json(res, 409, { error: 'project has an active mission' });
1499
1828
  }
1500
1829
  const removed = await store.removeProject(projectMatch[1]);
1830
+ // Its planning conversation goes with it; a proposal for a project
1831
+ // that no longer exists once showed up in /status as a bare id.
1832
+ if (removed) await store.clearChat(projectMatch[1]).catch(() => {});
1501
1833
  json(res, removed ? 200 : 404, removed ? { ok: true } : { error: 'unknown project' });
1502
1834
 
1503
1835
  } else if (req.method === 'POST' && url.pathname === '/run') {
@@ -1659,6 +1991,20 @@ const server = http.createServer(async (req, res) => {
1659
1991
  // Who answers here, for the bar's footer before any turn has run.
1660
1992
  // Best-effort: a project whose provider cannot resolve still gets its
1661
1993
  // transcript, and the first turn will say what went wrong.
1994
+ // The fleet planner's conversation answers on the same route: same
1995
+ // log shape, same hook in the UI, its own idea of who answers and
1996
+ // whether a reply is in flight.
1997
+ if (projectId === FLEET_CHAT_ID) {
1998
+ const g = (await store.readSettings().catch(() => ({ global: {}, projects: {} }))).global as Record<string, unknown>;
1999
+ const resolvedFleet = await resolveProvider(providerOf({}), store.root).catch(() => null);
2000
+ return json(res, 200, {
2001
+ events, costUsd: meta.costUsd, proposal: null, thinking: Boolean(fleetAbort), question: null,
2002
+ who: resolvedFleet ? {
2003
+ model: modelChoice(g.fleetPlannerModel ?? g.plannerModel) || DEFAULT_FLEET_MODEL,
2004
+ provider: resolvedFleet.label, costBasis: resolvedFleet.costBasis,
2005
+ } : null,
2006
+ });
2007
+ }
1662
2008
  const project = await store.getProject(projectId);
1663
2009
  const settings = await effectiveSettings(projectId).catch(() => null);
1664
2010
  const resolved = project
@@ -1682,7 +2028,7 @@ const server = http.createServer(async (req, res) => {
1682
2028
 
1683
2029
  } else if (req.method === 'DELETE') {
1684
2030
  if (!projectId) return json(res, 400, { error: 'projectId is required' });
1685
- if (chatTurns.has(projectId)) {
2031
+ if (chatTurns.has(projectId) || (projectId === FLEET_CHAT_ID && fleetAbort)) {
1686
2032
  return json(res, 409, { error: 'the planner is mid-reply — wait for it to finish' });
1687
2033
  }
1688
2034
  await store.clearChat(projectId).catch(() => {});
@@ -1695,6 +2041,13 @@ const server = http.createServer(async (req, res) => {
1695
2041
  if (typeof id !== 'string' || !message) {
1696
2042
  return json(res, 400, { error: 'projectId and text are required' });
1697
2043
  }
2044
+ if (id === FLEET_CHAT_ID) {
2045
+ // The front desk from the fleet page. The reply streams on the
2046
+ // chat frames like a project planner's; the POST returns at once.
2047
+ if (fleetAbort) return json(res, 409, { error: 'the fleet planner is still replying' });
2048
+ void driveFleetTurn(message, 'http');
2049
+ return json(res, 200, { ok: true });
2050
+ }
1698
2051
  const project = await store.getProject(id);
1699
2052
  if (!project) return json(res, 404, { error: 'unknown project' });
1700
2053
  // While a mission runs, the director is who you talk to — the same
@@ -1734,6 +2087,11 @@ const server = http.createServer(async (req, res) => {
1734
2087
  // with a line saying it was stopped. The conversation stays usable.
1735
2088
  const { projectId: id } = await readBody(req);
1736
2089
  if (typeof id !== 'string') return json(res, 400, { error: 'projectId is required' });
2090
+ if (id === FLEET_CHAT_ID) {
2091
+ const was = Boolean(fleetAbort);
2092
+ fleetAbort?.abort();
2093
+ return json(res, 200, { ok: true, stopped: was });
2094
+ }
1737
2095
  const abort = chatAborts.get(id);
1738
2096
  if (!abort) return json(res, 200, { ok: true, stopped: false });
1739
2097
  dropPendingAsk(id);
@@ -1835,6 +2193,27 @@ const server = http.createServer(async (req, res) => {
1835
2193
  if (!ok) return json(res, 404, { error: 'no pending question with that id' });
1836
2194
  json(res, 200, { ok: true });
1837
2195
 
2196
+ } else if (req.method === 'POST' && url.pathname === '/fleet/chat') {
2197
+ // One turn at the front desk, answered in the response. The same
2198
+ // session the phone uses, so a conversation can move between them.
2199
+ const { text } = await readBody(req);
2200
+ const trimmed = typeof text === 'string' ? text.trim() : '';
2201
+ if (!trimmed) return json(res, 400, { error: 'text is required' });
2202
+ const r = await driveFleetTurn(trimmed, 'http');
2203
+ if (r.busy) return json(res, 409, { error: 'the fleet planner is still replying' });
2204
+ json(res, 200, r);
2205
+
2206
+ } else if (req.method === 'POST' && url.pathname === '/fleet/stop') {
2207
+ if (!fleetAbort) return json(res, 404, { error: 'no fleet planner reply in flight' });
2208
+ fleetAbort.abort();
2209
+ json(res, 200, { ok: true });
2210
+
2211
+ } else if (req.method === 'DELETE' && url.pathname === '/fleet/chat') {
2212
+ if (fleetAbort) return json(res, 409, { error: 'the fleet planner is mid-reply — stop it first' });
2213
+ await store.clearChat(FLEET_CHAT_ID).catch(() => {});
2214
+ broadcastChat(FLEET_CHAT_ID, 'chat_cleared', {});
2215
+ json(res, 200, { ok: true });
2216
+
1838
2217
  } else if (req.method === 'POST' && url.pathname === '/steer') {
1839
2218
  const { runId, text } = await readBody(req);
1840
2219
  const trimmed = typeof text === 'string' ? text.trim() : '';
package/src/store.ts CHANGED
@@ -293,10 +293,14 @@ export class RunStore {
293
293
  return next;
294
294
  }
295
295
 
296
- /** Every project id that has a planning conversation on disk. */
296
+ /**
297
+ * Every project id that has a planning conversation on disk. Names that
298
+ * start with an underscore are Foreman's own conversations (the fleet
299
+ * planner's), stored in the same shape but belonging to no project.
300
+ */
297
301
  async listChatIds(): Promise<string[]> {
298
302
  const names = await readdir(this.chatsDir).catch(() => [] as string[]);
299
- return names.filter((n) => !n.startsWith('.'));
303
+ return names.filter((n) => !n.startsWith('.') && !n.startsWith('_'));
300
304
  }
301
305
 
302
306
  /** Reads a chat's full event log; skips lines that fail to parse. */