@amenophis1er/foreman 0.1.5 → 0.1.7

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