@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/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amenophis1er/foreman",
|
|
3
|
-
"version": "0.1.
|
|
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
|
+
});
|
package/src/notify/commands.ts
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
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 <project> <what you want> — talk to that project\'s planner',
|
|
69
71
|
'/run <project> <brief> — 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,
|
|
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');
|
package/src/notify/telegram.ts
CHANGED
|
@@ -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
|
-
?
|
|
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
|
|