@amenophis1er/foreman 0.1.5 → 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
 
@@ -1494,6 +1827,9 @@ const server = http.createServer(async (req, res) => {
1494
1827
  return json(res, 409, { error: 'project has an active mission' });
1495
1828
  }
1496
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(() => {});
1497
1833
  json(res, removed ? 200 : 404, removed ? { ok: true } : { error: 'unknown project' });
1498
1834
 
1499
1835
  } else if (req.method === 'POST' && url.pathname === '/run') {
@@ -1655,6 +1991,20 @@ const server = http.createServer(async (req, res) => {
1655
1991
  // Who answers here, for the bar's footer before any turn has run.
1656
1992
  // Best-effort: a project whose provider cannot resolve still gets its
1657
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
+ }
1658
2008
  const project = await store.getProject(projectId);
1659
2009
  const settings = await effectiveSettings(projectId).catch(() => null);
1660
2010
  const resolved = project
@@ -1678,7 +2028,7 @@ const server = http.createServer(async (req, res) => {
1678
2028
 
1679
2029
  } else if (req.method === 'DELETE') {
1680
2030
  if (!projectId) return json(res, 400, { error: 'projectId is required' });
1681
- if (chatTurns.has(projectId)) {
2031
+ if (chatTurns.has(projectId) || (projectId === FLEET_CHAT_ID && fleetAbort)) {
1682
2032
  return json(res, 409, { error: 'the planner is mid-reply — wait for it to finish' });
1683
2033
  }
1684
2034
  await store.clearChat(projectId).catch(() => {});
@@ -1691,6 +2041,13 @@ const server = http.createServer(async (req, res) => {
1691
2041
  if (typeof id !== 'string' || !message) {
1692
2042
  return json(res, 400, { error: 'projectId and text are required' });
1693
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
+ }
1694
2051
  const project = await store.getProject(id);
1695
2052
  if (!project) return json(res, 404, { error: 'unknown project' });
1696
2053
  // While a mission runs, the director is who you talk to — the same
@@ -1730,6 +2087,11 @@ const server = http.createServer(async (req, res) => {
1730
2087
  // with a line saying it was stopped. The conversation stays usable.
1731
2088
  const { projectId: id } = await readBody(req);
1732
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
+ }
1733
2095
  const abort = chatAborts.get(id);
1734
2096
  if (!abort) return json(res, 200, { ok: true, stopped: false });
1735
2097
  dropPendingAsk(id);
@@ -1831,6 +2193,27 @@ const server = http.createServer(async (req, res) => {
1831
2193
  if (!ok) return json(res, 404, { error: 'no pending question with that id' });
1832
2194
  json(res, 200, { ok: true });
1833
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
+
1834
2217
  } else if (req.method === 'POST' && url.pathname === '/steer') {
1835
2218
  const { runId, text } = await readBody(req);
1836
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. */
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { isTailscaleIp, parseTailscaleStatus, tailnetFromInterfaces, tailnetUrl } from './tailscale.js';
3
+ import { isTailscaleIp, parseTailscaleStatus, tailnetFromInterfaces, tailnetUrl, parseServeStatus, serveHint } from './tailscale.js';
4
4
 
5
5
  test('isTailscaleIp: only 100.64.0.0/10', () => {
6
6
  assert.equal(isTailscaleIp('100.94.221.98'), true);
@@ -30,3 +30,29 @@ test('tailnetFromInterfaces: finds the CGNAT address, ignores loopback and LAN',
30
30
  assert.deepEqual(t, { ip: '100.94.221.98' });
31
31
  assert.equal(tailnetFromInterfaces({ en0: [{ address: '192.168.1.5', family: 'IPv4', internal: false } as never] }), null);
32
32
  });
33
+
34
+ test('parseServeStatus finds the HTTPS port that proxies to Foreman, and the ports already taken', () => {
35
+ const json = {
36
+ TCP: { 443: { HTTPS: true }, 8443: { HTTPS: true } },
37
+ Web: {
38
+ 'laptop.tail1234.ts.net:443': { Handlers: { '/': { Proxy: 'http://127.0.0.1:7717' } } },
39
+ 'laptop.tail1234.ts.net:8443': { Handlers: { '/': { Proxy: 'http://127.0.0.1:4177' } } },
40
+ },
41
+ };
42
+ assert.deepEqual(parseServeStatus(json, 4177), { httpsPort: 8443, httpsInUse: [443, 8443] });
43
+ assert.deepEqual(parseServeStatus(json, 4178), { httpsPort: undefined, httpsInUse: [443, 8443] });
44
+ assert.deepEqual(parseServeStatus({}, 4177), { httpsPort: undefined, httpsInUse: [] });
45
+ });
46
+
47
+ test('tailnetUrl prefers the served HTTPS origin, port only when not 443', () => {
48
+ const t = { ip: '100.64.0.1', dnsName: 'laptop.tail1234.ts.net' };
49
+ assert.equal(tailnetUrl({ ...t, httpsPort: 443 }, 4177), 'https://laptop.tail1234.ts.net');
50
+ assert.equal(tailnetUrl({ ...t, httpsPort: 8443 }, 4177), 'https://laptop.tail1234.ts.net:8443');
51
+ assert.equal(tailnetUrl(t, 4177), 'http://laptop.tail1234.ts.net:4177');
52
+ });
53
+
54
+ test('serveHint picks 443 when free, else the next conventional port', () => {
55
+ assert.equal(serveHint(4177), 'tailscale serve --bg 4177');
56
+ assert.equal(serveHint(4177, [443]), 'tailscale serve --bg --https=8443 4177');
57
+ assert.equal(serveHint(4177, [443, 8443]), 'tailscale serve --bg --https=10000 4177');
58
+ });