@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/README.md +4 -0
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/fleet-planner.test.ts +54 -0
- package/src/fleet-planner.ts +379 -0
- package/src/notify/commands.test.ts +6 -0
- package/src/notify/commands.ts +7 -4
- package/src/notify/telegram.ts +11 -0
- package/src/notify.ts +11 -0
- package/src/planner.ts +1 -1
- package/src/preflight.ts +4 -2
- package/src/server.ts +427 -48
- package/src/store.ts +6 -2
- package/src/tailscale.test.ts +27 -1
- package/src/tailscale.ts +58 -4
- package/ui/dist/assets/index-btSPOnoZ.js +67 -0
- package/ui/dist/assets/index-tY7na0em.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-D_tuMLCb.js +0 -66
- package/ui/dist/assets/index-LAj0Dy9p.css +0 -1
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
|
|
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 <name> 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
|
-
|
|
722
|
-
|
|
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 (
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
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
|
|
1159
|
-
// failure takes effect on the retry.
|
|
1160
|
-
//
|
|
1161
|
-
// fresh (the mission doc carries the state forward).
|
|
1162
|
-
const
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
const
|
|
1175
|
-
(
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
-
/**
|
|
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. */
|