@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 CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="assets/brand/logo.svg" alt="" width="72" height="72">
3
+ </p>
4
+
1
5
  # Foreman
2
6
 
3
7
  Foreman runs software missions without you in the loop, and shows you
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amenophis1er/foreman",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.ts CHANGED
@@ -319,7 +319,7 @@ async function update(bin: string, flags: string[]): Promise<number> {
319
319
  }
320
320
 
321
321
  async function doctor(): Promise<number> {
322
- const tailnet = await detectTailscale();
322
+ const tailnet = await detectTailscale(PORT);
323
323
  const distDir = fileURLToPath(new URL('../ui/dist', import.meta.url));
324
324
  const checks = await preflight({ port: PORT, foremanHome: HOME_DIR, distDir, tailnet });
325
325
  // Port-in-use is an error for `start` and a fact for `doctor`: it usually means Foreman is already up.
@@ -0,0 +1,54 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { FLEET_CHAT_ID, PHONE_CONTEXT_MS, fleetSummary, phoneRoute, situation } from './fleet-planner.js';
4
+
5
+ test('plain phone text goes to the fleet planner when no project conversation is open', () => {
6
+ assert.equal(phoneRoute(null), 'fleet');
7
+ });
8
+
9
+ test('plain phone text continues a project planner spoken to within the context window', () => {
10
+ const now = 1_000_000_000;
11
+ assert.equal(phoneRoute({ projectId: 'p1', at: now - 60_000 }, now), 'project');
12
+ assert.equal(phoneRoute({ projectId: 'p1', at: now - PHONE_CONTEXT_MS }, now), 'project');
13
+ });
14
+
15
+ test('after the context window the front desk answers again', () => {
16
+ const now = 1_000_000_000;
17
+ assert.equal(phoneRoute({ projectId: 'p1', at: now - PHONE_CONTEXT_MS - 1 }, now), 'fleet');
18
+ });
19
+
20
+ test('the fleet chat id is one the store accepts and project listings skip', () => {
21
+ assert.match(FLEET_CHAT_ID, /^[A-Za-z0-9_-]{1,64}$/);
22
+ assert.ok(FLEET_CHAT_ID.startsWith('_'));
23
+ });
24
+
25
+ test('fleetSummary says what is running, what is waiting, and what finished last', () => {
26
+ const now = 10 * 60_000 * 100;
27
+ const text = fleetSummary([
28
+ {
29
+ id: 'a', name: 'P5', folder: '/x/P5', proposalWaiting: false, plannerReplying: false,
30
+ running: { title: 'Build Tick', spend: '$0.54 of $5', startedAt: now - 47 * 60_000, waiting: ['approval: browser_navigate file:///x/P5/index.html'] },
31
+ },
32
+ {
33
+ id: 'b', name: 'P7', folder: '/x/P7', proposalWaiting: true, plannerReplying: false,
34
+ lastRun: { title: 'Pomodoro', status: 'done', endedAt: now - 3 * 3_600_000 },
35
+ },
36
+ { id: 'c', name: 'fresh', folder: '/x/fresh', proposalWaiting: false, plannerReplying: true },
37
+ ], now);
38
+ assert.match(text, /P5 \(\/x\/P5\)\n RUNNING "Build Tick" · \$0\.54 of \$5 · started 47 min ago\n waiting on the human: approval: browser_navigate/);
39
+ assert.match(text, /P7 .*\n idle · last run "Pomodoro" done 3 h ago\n a mission proposal is waiting/);
40
+ assert.match(text, /fresh .*\n idle · no runs yet\n its planner is replying right now/);
41
+ assert.equal(fleetSummary([]), 'No projects are linked yet.');
42
+ });
43
+
44
+ test('situation carries the clock, the channel, and the news since the last message', () => {
45
+ const now = new Date(2026, 8, 6, 14, 5);
46
+ const s = situation({ via: 'telegram', sinceMs: 12 * 60_000, news: ['13:58 P7 — mission ended: done ($0.78)'] }, now);
47
+ assert.match(s, /NOW: .*2026.*14:05|NOW: .*2:05/);
48
+ assert.match(s, /ARRIVED VIA TELEGRAM/);
49
+ assert.match(s, /SINCE THE HUMAN'S LAST MESSAGE \(12 min ago\)/);
50
+ assert.match(s, /- 13:58 P7 — mission ended: done/);
51
+ const quiet = situation({ via: 'http', sinceMs: 2 * 3_600_000, news: [] }, now);
52
+ assert.match(quiet, /ARRIVED VIA THE DESK/);
53
+ assert.match(quiet, /Nothing notable happened .*\(2\.0 h ago\)/);
54
+ });
@@ -0,0 +1,379 @@
1
+ /**
2
+ * The fleet planner — the front desk you reach when no project is in play.
3
+ *
4
+ * The project planner answers "what should we build in this folder?". This
5
+ * one answers everything above that: "how is P5 doing", "start something in
6
+ * spade for a pomodoro timer", "tell the director to skip the mobile
7
+ * screenshot", "what did worker-3 crash on". It is the natural-language face
8
+ * of the fleet, built for the phone, where slash commands are the least
9
+ * forgiving interface there is.
10
+ *
11
+ * It lives by the same three rules as the project planner, and they are what
12
+ * keep it a concierge rather than a chat client:
13
+ *
14
+ * - **No resident process.** A message resumes a stored session, runs one
15
+ * turn, exits. Continuity is the session id on disk.
16
+ * - **Read-only, always.** Its tools are the fleet's verbs — list, inspect,
17
+ * create or link a project, open a planning conversation, propose, steer.
18
+ * No shell, no file access, no starting missions.
19
+ * - **It never answers for the human.** Open approvals and questions are
20
+ * described, not resolved. The buttons on the card are the human's, and an
21
+ * agent that presses them is a hole through `canUseTool`.
22
+ */
23
+ import { z } from 'zod';
24
+ import {
25
+ query, tool, createSdkMcpServer,
26
+ type CanUseTool, type PermissionResult, type SDKMessage,
27
+ } from '@anthropic-ai/claude-agent-sdk';
28
+ import type { AgentEnv } from './provider.js';
29
+ import { modelsSection, needsBrowser, pickKnownModel, type PlannerModel } from './planner.js';
30
+ import type { MissionProposal } from './types.js';
31
+
32
+ /** Where the fleet conversation is stored, beside the project chats. The underscore keeps it out of project listings. */
33
+ export const FLEET_CHAT_ID = '_fleet';
34
+
35
+ /** Fast and reliable at tool calls matters more than depth at a front desk. */
36
+ export const DEFAULT_FLEET_MODEL = 'sonnet';
37
+
38
+ /**
39
+ * How long a phone planning conversation stays "the one you are in". Plain
40
+ * text within this window continues the project planner you last spoke to;
41
+ * after it, the front desk answers. Conversations have recency, and a
42
+ * question typed the next morning is rarely a reply to yesterday's planner.
43
+ */
44
+ export const PHONE_CONTEXT_MS = 30 * 60_000;
45
+
46
+ /** One tool call's worth of listing is plenty; a front desk that keeps digging has lost the thread. */
47
+ const MAX_TURNS = 12;
48
+
49
+ /**
50
+ * Where a plain phone message goes. Pure, so the rule is testable: the
51
+ * project planner you were just talking to, or the fleet planner otherwise.
52
+ */
53
+ export function phoneRoute(
54
+ last: { projectId: string; at: number } | null, now = Date.now(), idleMs = PHONE_CONTEXT_MS,
55
+ ): 'project' | 'fleet' {
56
+ if (!last) return 'fleet';
57
+ return now - last.at <= idleMs ? 'project' : 'fleet';
58
+ }
59
+
60
+ /** One project as the front desk sees it. Built by the server from live state. */
61
+ export interface FleetProjectView {
62
+ id: string;
63
+ name: string;
64
+ folder: string;
65
+ /** Present while a mission is running there. */
66
+ running?: {
67
+ title: string;
68
+ /** "$0.54 of $5" or "unpriced". */
69
+ spend: string;
70
+ startedAt: number;
71
+ /** Approvals and questions waiting on the human, in words. */
72
+ waiting: string[];
73
+ };
74
+ /** The most recent finished run, when there is one. */
75
+ lastRun?: { title: string; status: string; endedAt?: number };
76
+ proposalWaiting: boolean;
77
+ plannerReplying: boolean;
78
+ }
79
+
80
+ const ago = (ms: number): string => {
81
+ const m = Math.round(ms / 60_000);
82
+ if (m < 1) return 'just now';
83
+ if (m < 60) return `${m} min ago`;
84
+ const h = Math.round(m / 60);
85
+ return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`;
86
+ };
87
+
88
+ /** The fleet in plain lines, as the list_projects tool returns it. */
89
+ export function fleetSummary(views: FleetProjectView[], now = Date.now()): string {
90
+ if (!views.length) return 'No projects are linked yet.';
91
+ return views.map((p) => {
92
+ const bits: string[] = [];
93
+ if (p.running) {
94
+ bits.push(`RUNNING "${p.running.title}" · ${p.running.spend} · started ${ago(now - p.running.startedAt)}`);
95
+ if (p.running.waiting.length) bits.push(`waiting on the human: ${p.running.waiting.join('; ')}`);
96
+ } else if (p.lastRun) {
97
+ bits.push(`idle · last run "${p.lastRun.title}" ${p.lastRun.status}${p.lastRun.endedAt ? ` ${ago(now - p.lastRun.endedAt)}` : ''}`);
98
+ } else {
99
+ bits.push('idle · no runs yet');
100
+ }
101
+ if (p.proposalWaiting) bits.push('a mission proposal is waiting for Start or Discard');
102
+ if (p.plannerReplying) bits.push('its planner is replying right now');
103
+ return `- ${p.name} (${p.folder})\n ${bits.join('\n ')}`;
104
+ }).join('\n');
105
+ }
106
+
107
+ /**
108
+ * What the server lets the front desk do. Every method returns text for the
109
+ * model, never throws, and the ones with side effects are exactly the verbs
110
+ * the slash commands already had. Nothing here starts a run.
111
+ */
112
+ export interface FleetHost {
113
+ listProjects(): Promise<FleetProjectView[]>;
114
+ /** Live detail for one project: run, boxes, crew, open asks, the director's last words. */
115
+ projectDetail(ref: string): Promise<string>;
116
+ /** The last finished run's closing report and error, for "what happened". */
117
+ runReport(ref: string): Promise<string>;
118
+ createProject(name: string): Promise<string>;
119
+ linkProject(folder: string): Promise<string>;
120
+ /** Hands the conversation to that project's planner. The reply comes from there. */
121
+ openPlanning(ref: string, message: string): Promise<string>;
122
+ /** Puts a proposal card in that project's chat, on the phone and on the desk. */
123
+ proposeMission(ref: string, proposal: Omit<MissionProposal, 'id' | 'createdAt'>): Promise<string>;
124
+ /** An operator note to a running director. */
125
+ steer(ref: string, note: string): Promise<string>;
126
+ }
127
+
128
+ const CHARTER = `
129
+ You are FOREMAN'S FRONT DESK — the fleet planner. The human reaches you from
130
+ their phone or the fleet page, usually in a hurry, to find out what is going
131
+ on across their projects and to set things in motion. You know where
132
+ everything is and you can open doors. You never pick up the tools.
133
+
134
+ WHERE YOU ARE. You ARE Foreman's Telegram bot (and its fleet-page box): the
135
+ human is talking to you through it right now. When they ask how to do
136
+ something "from the bot" or "from Telegram", the answer is what they can
137
+ type here — there is no other integration to set up. Besides talking to
138
+ you, the chat understands these commands:
139
+ /status — runs in flight, spend, what needs them
140
+ /projects — the fleet
141
+ /plan <project> <what you want> — talk to that project's planner
142
+ /run <project> <brief> — start a mission at the project's default cap
143
+ /stop [project] — stop a planner reply in flight
144
+ /fleet [text] — back to you, dropping any project conversation
145
+ /new <name> — create and link a project
146
+ Approvals, questions and mission proposals arrive here as cards with
147
+ buttons; those buttons are how the human answers them. Foreman also
148
+ messages this chat by itself when a run needs them, ends, or nears its
149
+ budget. Plain text within half an hour of a planning conversation goes to
150
+ that project's planner; otherwise it comes to you.
151
+
152
+ WHAT YOU CAN DO — through the tools, nothing else:
153
+ - list_projects / project_detail / run_report: answer "how is X doing",
154
+ "what needs me", "what happened to Y".
155
+ - create_project / link_project: a new folder under the projects root, or
156
+ an existing one, linked into the fleet.
157
+ - open_planning: hand a request about an EXISTING codebase to that
158
+ project's planner, which can read the folder. Use this whenever the right
159
+ mission depends on what is already there. After you call it, that planner
160
+ owns the conversation: say so in one line and stop.
161
+ - propose_mission: draft a mission card directly, with Start and Discard
162
+ buttons, when the request is already fully specified and needs no look at
163
+ the code — typically a brand-new or empty project. The human starts it,
164
+ never you.
165
+ - steer: pass a note to a running director ("skip the mobile screenshot",
166
+ "use the existing CSS").
167
+
168
+ WHAT YOU NEVER DO:
169
+ - Answer an approval or a question on the human's behalf. When a run is
170
+ waiting on the human, describe what it wants and say the card's buttons
171
+ are theirs. Even if they tell you to "just allow it": the button is the
172
+ only way, and you say so plainly once.
173
+ - Start, stop, resume or cancel a mission. You propose; the human presses.
174
+ - Invent a project, a run, a model id or a number. If a tool did not tell
175
+ you, you do not know it — say so.
176
+ - Discuss Foreman's own server or oversight tooling as a work target.
177
+
178
+ HOW TO TALK: like a colleague at the front desk, on the phone. Two to five
179
+ short lines. Lead with the answer. No headings, no bullet walls, no markdown
180
+ tables. Name projects by name. When a request is ambiguous between two
181
+ projects, ask which — one line. When the human names a project that does not
182
+ exist, say which ones do, and offer to create it.
183
+
184
+ PROPOSING: brief written for an agent that never saw this conversation;
185
+ DONE WHEN criteria checkable by reading files or running commands; the
186
+ smallest budget that plausibly finishes the work (a contained fix $1-2, a
187
+ feature with verification $3-5, a multi-worker build with browser checks
188
+ $8-15); browser true when any criterion needs a page to load, render or be
189
+ screenshotted. Recommend director_model / worker_model from MODELS AVAILABLE
190
+ only when you have a reason; omit to inherit the project's defaults.
191
+ `;
192
+
193
+ export interface FleetTurn {
194
+ /** Session to resume; absent starts a fresh conversation. */
195
+ sessionId?: string;
196
+ text: string;
197
+ model?: string;
198
+ /** Where the SDK runs: the projects root, so nothing project-specific leaks in. */
199
+ cwd: string;
200
+ agentEnv: AgentEnv;
201
+ host: FleetHost;
202
+ /** What the machine can run, for propose_mission recommendations. */
203
+ models?: PlannerModel[];
204
+ /** Who asked: the phone, or an HTTP caller (the fleet page, a curl). */
205
+ via?: 'telegram' | 'http';
206
+ /** One line per notable fleet event since the previous turn, oldest first. */
207
+ news?: string[];
208
+ /** How long ago the previous turn ended. */
209
+ sinceMs?: number;
210
+ emit: (event: string, data: unknown) => void;
211
+ abort?: AbortController;
212
+ }
213
+
214
+ export interface FleetResult {
215
+ sessionId?: string;
216
+ costUsd: number;
217
+ /** The reply's last words, for the phone. Empty when a handoff or a card spoke instead. */
218
+ said: string;
219
+ /** True when the turn ended by handing the conversation to a project planner. */
220
+ handedOff?: string;
221
+ error?: string;
222
+ stopped?: boolean;
223
+ }
224
+
225
+ /**
226
+ * What a receptionist knows without being told: the time, where the
227
+ * message came from, and what happened since the human last spoke. Rebuilt
228
+ * every turn, appended after the charter so it is never stale.
229
+ */
230
+ export function situation(turn: Pick<FleetTurn, 'via' | 'news' | 'sinceMs'>, now = new Date()): string {
231
+ const when = now.toLocaleString(undefined, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' });
232
+ const lines = [
233
+ '',
234
+ `NOW: ${when}. Use this for "today", "this morning", "an hour ago"; never guess the time.`,
235
+ `THIS MESSAGE ARRIVED VIA ${turn.via === 'telegram' ? 'TELEGRAM' : 'THE DESK (HTTP)'}.`,
236
+ ];
237
+ const gap = turn.sinceMs === undefined ? '' : turn.sinceMs < 90_000 ? 'moments ago' : turn.sinceMs < 3_600_000 ? `${Math.round(turn.sinceMs / 60_000)} min ago` : `${(turn.sinceMs / 3_600_000).toFixed(1)} h ago`;
238
+ if (turn.news?.length) {
239
+ lines.push(`SINCE THE HUMAN'S LAST MESSAGE${gap ? ` (${gap})` : ''}, oldest first:`);
240
+ for (const n of turn.news) lines.push(` - ${n}`);
241
+ lines.push('Lead with what matters from this if it is relevant to what they ask; do not recite it otherwise.');
242
+ } else if (gap) {
243
+ lines.push(`Nothing notable happened in the fleet since the human's last message (${gap}).`);
244
+ }
245
+ return lines.join('\n') + '\n';
246
+ }
247
+
248
+ /** No built-in tools at all: the front desk sees the fleet through its own verbs only. */
249
+ const canUseTool: CanUseTool = async (toolName, input): Promise<PermissionResult> => {
250
+ if (toolName.startsWith('mcp__fleet__')) return { behavior: 'allow', updatedInput: input };
251
+ return {
252
+ behavior: 'deny',
253
+ message: `The front desk cannot use ${toolName}. It sees the fleet through its own tools only; ` +
254
+ 'work belongs in a mission, and reading a project belongs to that project\'s planner (open_planning).',
255
+ };
256
+ };
257
+
258
+ const text = (t: string) => ({ content: [{ type: 'text' as const, text: t }] });
259
+
260
+ /**
261
+ * Runs one turn of the fleet conversation to completion. Never throws: a
262
+ * failed turn is reported as text and the session stays usable.
263
+ */
264
+ export async function runFleetTurn(turn: FleetTurn): Promise<FleetResult> {
265
+ const { host } = turn;
266
+ let handedOff: string | undefined;
267
+ let carded = false;
268
+
269
+ const safe = (fn: () => Promise<string>) => fn().catch((err) => `That failed: ${err instanceof Error ? err.message : String(err)}`);
270
+
271
+ const tools = [
272
+ tool('list_projects', 'Every linked project with what is running, what finished last, and what is waiting on the human.', {},
273
+ async () => text(fleetSummary(await host.listProjects()))),
274
+ tool('project_detail', 'Live detail for one project: the running mission, its DONE WHEN progress, crew, open approvals or questions, and the director\'s latest words.',
275
+ { project: z.string().describe('Project name, id, or folder name') },
276
+ async ({ project }) => text(await safe(() => host.projectDetail(project)))),
277
+ tool('run_report', 'The most recent finished run of a project: how it ended, the director\'s closing report, and the error if it failed.',
278
+ { project: z.string().describe('Project name, id, or folder name') },
279
+ async ({ project }) => text(await safe(() => host.runReport(project)))),
280
+ tool('create_project', 'Create a new folder under the projects root and link it as a project. Use when the human wants to start something that has no home yet.',
281
+ { name: z.string().describe('What to call it; becomes the folder name') },
282
+ async ({ name }) => text(await safe(() => host.createProject(name)))),
283
+ tool('link_project', 'Link an existing folder as a project. The folder must already exist.',
284
+ { folder: z.string().describe('Absolute path, or ~/…') },
285
+ async ({ folder }) => text(await safe(() => host.linkProject(folder)))),
286
+ tool('open_planning', 'Hand the request to that project\'s planner, which can read the folder and will propose a mission. After this, the planner owns the conversation: tell the human in one line and stop.',
287
+ {
288
+ project: z.string().describe('Project name, id, or folder name'),
289
+ message: z.string().describe('The human\'s request, in their words plus any context you gathered'),
290
+ },
291
+ async ({ project, message }) => {
292
+ const out = await safe(() => host.openPlanning(project, message));
293
+ if (!out.startsWith('That failed') && !out.startsWith('No project')) handedOff = project;
294
+ return text(out);
295
+ }),
296
+ tool('propose_mission', 'Draft a mission card for a project, with Start and Discard buttons. Only when the request is fully specified and needs no reading of existing code. The human starts it.',
297
+ {
298
+ project: z.string().describe('Project name, id, or folder name'),
299
+ mission: z.string().describe('The brief, written for an agent that has not seen this conversation'),
300
+ done_when: z.array(z.string()).min(1).describe('Checkable completion criteria'),
301
+ budget_usd: z.number().describe('Suggested cap, justified by the size of the work'),
302
+ rationale: z.string().optional().describe('One short paragraph: why this shape and this budget'),
303
+ browser: z.boolean().optional().describe('True when a criterion needs a page to load, render or be screenshotted'),
304
+ director_model: z.string().optional().describe('Exact id from MODELS AVAILABLE; omit to inherit'),
305
+ worker_model: z.string().optional().describe('Exact id from MODELS AVAILABLE; omit to inherit'),
306
+ model_rationale: z.string().optional().describe('One line on why those models'),
307
+ },
308
+ async ({ project, mission, done_when, budget_usd, rationale, browser, director_model, worker_model, model_rationale }) => {
309
+ const director = pickKnownModel(director_model, turn.models);
310
+ const worker = pickKnownModel(worker_model, turn.models);
311
+ const out = await safe(() => host.proposeMission(project, {
312
+ mission, doneWhen: done_when, budgetUsd: budget_usd, rationale,
313
+ browser: browser === true || needsBrowser(mission, done_when) ? true : undefined,
314
+ directorModel: director?.id, workerModel: worker?.id,
315
+ directorProviderId: director?.providerId, workerProviderId: worker?.providerId,
316
+ modelRationale: director || worker ? model_rationale : undefined,
317
+ }));
318
+ if (!out.startsWith('That failed') && !out.startsWith('No project')) carded = true;
319
+ return text(out);
320
+ }),
321
+ tool('steer', 'Pass an operator note to the director of a running mission. It reads it at its next turn.',
322
+ {
323
+ project: z.string().describe('Project name, id, or folder name'),
324
+ note: z.string().describe('The note, in the human\'s words'),
325
+ },
326
+ async ({ project, note }) => text(await safe(() => host.steer(project, note)))),
327
+ ];
328
+
329
+ let sessionId = turn.sessionId;
330
+ let costUsd = 0;
331
+ let said = '';
332
+ let q: ReturnType<typeof query> | undefined;
333
+
334
+ try {
335
+ q = query({
336
+ prompt: turn.text,
337
+ options: {
338
+ cwd: turn.cwd,
339
+ resume: turn.sessionId,
340
+ model: turn.model || DEFAULT_FLEET_MODEL,
341
+ maxTurns: MAX_TURNS,
342
+ tools: [],
343
+ permissionMode: 'default',
344
+ systemPrompt: {
345
+ type: 'preset', preset: 'claude_code',
346
+ append: CHARTER + modelsSection(turn.models) + situation(turn),
347
+ },
348
+ mcpServers: { fleet: createSdkMcpServer({ name: 'fleet', tools }) },
349
+ canUseTool,
350
+ abortController: turn.abort,
351
+ ...turn.agentEnv,
352
+ },
353
+ });
354
+
355
+ let failed = false;
356
+ for await (const msg of q as AsyncIterable<SDKMessage>) {
357
+ const m = msg as Record<string, unknown>;
358
+ if (typeof m.session_id === 'string') sessionId = m.session_id;
359
+ if (m.type === 'assistant') {
360
+ const content = (m.message as { content?: Array<{ type?: string; text?: string }> } | undefined)?.content ?? [];
361
+ for (const b of content) if (b.type === 'text' && b.text?.trim()) said = b.text.trim();
362
+ }
363
+ if (m.type === 'result') {
364
+ if (typeof m.total_cost_usd === 'number') costUsd = m.total_cost_usd;
365
+ failed = Boolean(m.is_error);
366
+ }
367
+ turn.emit('message', { agent: 'fleet', msg });
368
+ }
369
+ if (turn.abort?.signal.aborted) return { sessionId, costUsd, said: '', stopped: true };
370
+ // A card already said it: a proposal's text on the phone plus the same
371
+ // words again from the desk reads as a stutter.
372
+ return { sessionId, costUsd, said: carded && !said ? '' : said, handedOff, error: failed ? 'the turn ended with an error' : undefined };
373
+ } catch (err) {
374
+ if (turn.abort?.signal.aborted) return { sessionId, costUsd, said: '', stopped: true };
375
+ return { sessionId, costUsd, said, handedOff, error: String(err) };
376
+ } finally {
377
+ await (q as AsyncGenerator<SDKMessage> | undefined)?.return?.(undefined as never).catch(() => {});
378
+ }
379
+ }
@@ -26,3 +26,9 @@ test('slug and roots', () => {
26
26
  assert.equal(projectsRoot('/srv/work', '/home/a'), '/srv/work');
27
27
  assert.equal(projectsRoot('rel', '/home/a'), '/home/a/rel');
28
28
  });
29
+
30
+ test('/fleet talks to the front desk, with or without words', () => {
31
+ assert.deepEqual(parseCommand('/fleet how is everything going?'), { cmd: 'fleet', text: 'how is everything going?' });
32
+ assert.deepEqual(parseCommand('/f'), { cmd: 'fleet', text: '' });
33
+ assert.deepEqual(parseCommand('/fleet@ForemanBot'), { cmd: 'fleet', text: '' });
34
+ });
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * The parser is pure and small on purpose: the phone is the least forgiving
5
5
  * place to discover a command's shape, so every command has one shape, a
6
- * `/help` lists them, and anything that is not a command is treated as talk
7
- * for the planner of the project last spoken to.
6
+ * `/help` lists them, and anything that is not a command is talk: for the
7
+ * planner of the project last spoken to, else for the fleet planner.
8
8
  */
9
9
  import os from 'node:os';
10
10
  import path from 'node:path';
@@ -16,7 +16,8 @@ export type Command =
16
16
  | { cmd: 'new'; name: string }
17
17
  | { cmd: 'plan'; project: string; text: string }
18
18
  | { cmd: 'run'; project: string; text: string }
19
- | { cmd: 'stop'; project?: string };
19
+ | { cmd: 'stop'; project?: string }
20
+ | { cmd: 'fleet'; text: string };
20
21
 
21
22
  /** `/plan@ForemanBot test-4 add a footer` → { cmd: 'plan', project: 'test-4', text: 'add a footer' }. */
22
23
  export function parseCommand(text: string): Command | null {
@@ -36,6 +37,7 @@ export function parseCommand(text: string): Command | null {
36
37
  case 'plan': { const [project, t] = split(); return project && t ? { cmd: 'plan', project, text: t } : null; }
37
38
  case 'run': { const [project, t] = split(); return project && t ? { cmd: 'run', project, text: t } : null; }
38
39
  case 'stop': return { cmd: 'stop', ...(rest ? { project: rest } : {}) };
40
+ case 'fleet': case 'f': return { cmd: 'fleet', text: rest };
39
41
  default: return null;
40
42
  }
41
43
  }
@@ -68,6 +70,7 @@ export const HELP_TEXT = [
68
70
  '/plan &lt;project&gt; &lt;what you want&gt; — talk to that project\'s planner',
69
71
  '/run &lt;project&gt; &lt;brief&gt; — skip the talk: start a mission at the project\'s default cap',
70
72
  '/stop [project] — stop the planner reply in flight',
73
+ '/fleet [anything] — the front desk: ask how things are going, or say what you want started where',
71
74
  '',
72
- 'Anything else you type answers the open question, or continues the last planning conversation.',
75
+ 'Anything else you type answers the open question, continues the planning conversation you were just in, or goes to the front desk.',
73
76
  ].join('\n');
@@ -69,6 +69,16 @@ export function telegramTransport(token: string, chatId: string, apiBase = TELEG
69
69
  ...(opts?.buttons ? keyboard(opts.buttons) : { reply_markup: { inline_keyboard: [] } }),
70
70
  });
71
71
  },
72
+ busy() {
73
+ // Telegram's typing bubble lasts about five seconds per call and there
74
+ // is no "stop" — it simply lapses. So: send now, repeat every four
75
+ // seconds, and stopping means letting it lapse.
76
+ const ping = () => void call(apiBase, token, 'sendChatAction', { chat_id: chatId, action: 'typing' }, 5_000);
77
+ ping();
78
+ const timer = setInterval(ping, 4_000);
79
+ timer.unref?.();
80
+ return () => clearInterval(timer);
81
+ },
72
82
  };
73
83
  }
74
84
 
@@ -88,6 +98,7 @@ export const BOT_COMMANDS: Array<{ command: string; description: string }> = [
88
98
  { command: 'plan', description: 'Talk to a planner: /plan <project> <what you want>' },
89
99
  { command: 'run', description: 'Skip the talk: /run <project> <brief>' },
90
100
  { command: 'stop', description: 'Stop the planner reply in flight' },
101
+ { command: 'fleet', description: 'The front desk: /fleet how is everything going?' },
91
102
  { command: 'help', description: 'What you can say here' },
92
103
  ];
93
104
 
package/src/notify.ts CHANGED
@@ -35,6 +35,8 @@ export interface Transport {
35
35
  send(text: string, opts?: { buttons?: Button[][] }): Promise<string | null>;
36
36
  /** Replaces the text and drops any buttons unless new ones are given. */
37
37
  edit(id: string, text: string, opts?: { buttons?: Button[][] }): Promise<void>;
38
+ /** Shows "working" for as long as the returned stop function is not called, where the channel has such a thing. */
39
+ busy?(): () => void;
38
40
  }
39
41
 
40
42
  /** The same envelope the SSE clients get, plus the event name. */
@@ -309,6 +311,15 @@ export class NotifyHub {
309
311
  constructor(private ctx: () => NotifyContext) {}
310
312
 
311
313
  attach(t: Transport): void { this.transports.push(t); }
314
+ /**
315
+ * The channel's "working" indicator, for the whole of a planner turn. A
316
+ * phone that shows nothing for thirty seconds and then a paragraph reads
317
+ * as a bot that ignored you; the typing bubble is the difference.
318
+ */
319
+ busy(): () => void {
320
+ const stops = this.transports.map((t) => t.busy?.()).filter((f): f is () => void => typeof f === 'function');
321
+ return () => { for (const s of stops) s(); };
322
+ }
312
323
  detach(name: string): void { this.transports = this.transports.filter((t) => t.name !== name); }
313
324
  get active(): string[] { return this.transports.map((t) => t.name); }
314
325
 
package/src/planner.ts CHANGED
@@ -202,7 +202,7 @@ export function pickKnownModel(
202
202
  }
203
203
 
204
204
  /** The section appended to the charter so the planner can recommend real models. */
205
- function modelsSection(models: PlannerModel[] | undefined): string {
205
+ export function modelsSection(models: PlannerModel[] | undefined): string {
206
206
  if (!models?.length) return '';
207
207
  const lines = models.map((m) =>
208
208
  ` - ${m.id} — ${m.providerLabel} · ${m.costBasis}${m.note ? ` · ${m.note}` : ''}`);
package/src/preflight.ts CHANGED
@@ -19,7 +19,7 @@ import { access, mkdir, readFile } from 'node:fs/promises';
19
19
  import { constants } from 'node:fs';
20
20
  import { defaultInstance, describeInstance, effectiveConfigDir } from './instance.js';
21
21
  import { discoverOllama, ollamaHost } from './ollama.js';
22
- import { tailnetUrl, type Tailnet } from './tailscale.js';
22
+ import { serveHint, tailnetUrl, type Tailnet } from './tailscale.js';
23
23
  import { codexHome, codexModels, readCodexAuth } from './codex.js';
24
24
  import { createRequire } from 'node:module';
25
25
  import { fileURLToPath } from 'node:url';
@@ -286,7 +286,9 @@ async function checkBrowser(): Promise<Check> {
286
286
  /** Where the phone can reach this. Says so plainly either way — the answer decides which links work. */
287
287
  function checkTailnet(t: Tailnet | null, port: number): Check {
288
288
  return t
289
- ? { name: 'Tailscale', status: 'ok', detail: `${tailnetUrl(t, port)} — listening there too; phone links use it` }
289
+ ? (t.httpsPort
290
+ ? { name: 'Tailscale', status: 'ok', detail: `${tailnetUrl(t, port)} — HTTPS via tailscale serve; phone links use it` }
291
+ : { name: 'Tailscale', status: 'ok', detail: `${tailnetUrl(t, port)} — listening there too; phone links use it. For HTTPS: ${serveHint(port, t.httpsInUse)}` })
290
292
  : { name: 'Tailscale', status: 'ok', detail: 'not running — localhost only; phone links need a public URL in Settings → Notifications' };
291
293
  }
292
294