@amenophis1er/foreman 0.1.0

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.
Files changed (65) hide show
  1. package/DESIGN.md +408 -0
  2. package/LICENSE +15 -0
  3. package/README.md +133 -0
  4. package/bin/foreman.mjs +58 -0
  5. package/package.json +68 -0
  6. package/scripts/prepare.mjs +48 -0
  7. package/skills/director/SKILL.md +65 -0
  8. package/src/anthropic-models.ts +54 -0
  9. package/src/ask.test.ts +88 -0
  10. package/src/ask.ts +95 -0
  11. package/src/attachments.test.ts +33 -0
  12. package/src/attachments.ts +60 -0
  13. package/src/cli.test.ts +27 -0
  14. package/src/cli.ts +297 -0
  15. package/src/codex.test.ts +328 -0
  16. package/src/codex.ts +196 -0
  17. package/src/cost-basis.test.ts +76 -0
  18. package/src/deck.test.ts +402 -0
  19. package/src/deck.ts +892 -0
  20. package/src/fork.test.ts +31 -0
  21. package/src/gateway/ledger.cjs +326 -0
  22. package/src/gateway/ledger.test.ts +255 -0
  23. package/src/gateway/llm-gateway.cjs +1411 -0
  24. package/src/gateway/llm-gateway.test.ts +478 -0
  25. package/src/gateway.test.ts +226 -0
  26. package/src/gateway.ts +309 -0
  27. package/src/instance.ts +124 -0
  28. package/src/models.test.ts +147 -0
  29. package/src/models.ts +158 -0
  30. package/src/notify/commands.test.ts +28 -0
  31. package/src/notify/commands.ts +73 -0
  32. package/src/notify/telegram.ts +259 -0
  33. package/src/notify.test.ts +343 -0
  34. package/src/notify.ts +495 -0
  35. package/src/ollama.test.ts +49 -0
  36. package/src/ollama.ts +49 -0
  37. package/src/openai-prices.test.ts +58 -0
  38. package/src/openai-prices.ts +106 -0
  39. package/src/orchestrator.test.ts +1147 -0
  40. package/src/orchestrator.ts +2325 -0
  41. package/src/planner.test.ts +60 -0
  42. package/src/planner.ts +505 -0
  43. package/src/policy.test.ts +411 -0
  44. package/src/policy.ts +599 -0
  45. package/src/preflight.ts +348 -0
  46. package/src/prices.test.ts +69 -0
  47. package/src/prices.ts +90 -0
  48. package/src/provider.test.ts +366 -0
  49. package/src/provider.ts +502 -0
  50. package/src/secrets.test.ts +143 -0
  51. package/src/secrets.ts +66 -0
  52. package/src/server.ts +1992 -0
  53. package/src/services.test.ts +53 -0
  54. package/src/services.ts +102 -0
  55. package/src/sse-events.test.ts +83 -0
  56. package/src/store.test.ts +119 -0
  57. package/src/store.ts +346 -0
  58. package/src/tailscale.test.ts +32 -0
  59. package/src/tailscale.ts +79 -0
  60. package/src/title.ts +138 -0
  61. package/src/types.ts +442 -0
  62. package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
  63. package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
  64. package/ui/dist/favicon.svg +8 -0
  65. package/ui/dist/index.html +14 -0
package/src/notify.ts ADDED
@@ -0,0 +1,495 @@
1
+ /**
2
+ * Notifications — a tap on the shoulder, wherever the human actually is.
3
+ *
4
+ * Foreman runs missions unattended, and every ask already carries an
5
+ * unattended default: ten minutes unanswered and the safe answer is applied.
6
+ * That keeps the mission alive by deciding in silence. A channel changes the
7
+ * layering to **notify → wait → default**: the human is told where they are,
8
+ * and the default becomes what happens when they genuinely cannot answer —
9
+ * not what happens because they never knew.
10
+ *
11
+ * Three rules shape everything here:
12
+ *
13
+ * - **One subscription point.** The hub hangs off the same broadcast the SSE
14
+ * clients receive; nothing in the orchestrator knows notifications exist.
15
+ * An event that reaches the UI can reach a phone, and one that does not,
16
+ * cannot — the SSE contract test already guards that list.
17
+ * - **Curated, not a firehose.** Only what a human should know: something
18
+ * needs them, something stalled, something was decided for them, a run
19
+ * ended. The existing Settings toggles (needs you / run finished / budget)
20
+ * are the filter, so a channel obeys the same preferences the tab does.
21
+ * - **Never a second answer pipeline.** Answering from the channel (Phase B)
22
+ * produces the same calls the picker and the approval card make — the
23
+ * server resolves them, emits the same events, and the transcript shows
24
+ * the same entry, tagged with where it came from. A message is edited
25
+ * when the thing it announced is resolved, whichever side resolved it.
26
+ */
27
+
28
+ /** One inline button. `data` must round-trip through the channel (Telegram caps it at 64 bytes). */
29
+ export interface Button { label: string; data: string }
30
+
31
+ /** A delivery channel. Never throws; a failed delivery is a lost tap, not a lost run. */
32
+ export interface Transport {
33
+ readonly name: string;
34
+ /** Returns an opaque message id if the channel supports editing later. */
35
+ send(text: string, opts?: { buttons?: Button[][] }): Promise<string | null>;
36
+ /** Replaces the text and drops any buttons unless new ones are given. */
37
+ edit(id: string, text: string, opts?: { buttons?: Button[][] }): Promise<void>;
38
+ }
39
+
40
+ /** The same envelope the SSE clients get, plus the event name. */
41
+ export interface Envelope {
42
+ event: string;
43
+ runId: string | null;
44
+ projectId: string;
45
+ chat?: boolean;
46
+ data: Record<string, unknown>;
47
+ ts?: number;
48
+ }
49
+
50
+ /** The three Settings toggles, reused as the channel's filter. */
51
+ export interface NotifyPrefs {
52
+ needsYou: boolean;
53
+ done: boolean;
54
+ budget: boolean;
55
+ }
56
+
57
+ export interface NotifyContext {
58
+ prefs: NotifyPrefs;
59
+ /** Where deep links point. A phone cannot open localhost; a LAN or Tailscale URL can. */
60
+ publicUrl: string;
61
+ /** For the message text: the project's name rather than its id. */
62
+ projectName?: (projectId: string) => string | undefined;
63
+ /** The run's title or brief, when known. */
64
+ runLabel?: (runId: string) => string | undefined;
65
+ }
66
+
67
+ /** What the channel can answer, in the shape the server's resolve routes take. */
68
+ export type Answer =
69
+ | { kind: 'perm'; id: string; behavior: 'allow' | 'deny' }
70
+ | { kind: 'q'; id: string; text: string }
71
+ | { kind: 'cq'; projectId: string; id: string; answers: Record<string, string> }
72
+ /** A tap on a proposal the planner made from the phone: start it as proposed, or drop it. */
73
+ | { kind: 'proposal'; projectId: string; action: 'start' | 'discard' };
74
+
75
+ /** How long an identical announcement is suppressed. */
76
+ export const DEDUPE_TTL_MS = 60_000;
77
+
78
+ export function escapeHtml(s: unknown): string { return esc(s); }
79
+ const esc = (s: unknown): string =>
80
+ String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
81
+ const clip = (s: unknown, n = 160): string => {
82
+ const t = String(s ?? '').replace(/\s+/g, ' ').trim();
83
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
84
+ };
85
+
86
+ /** One shaped announcement, or null when the event is not for a human. */
87
+ export interface Shaped {
88
+ /** Identity for dedupe and for the edit when it resolves. */
89
+ key: string;
90
+ /** Which preference gates it. */
91
+ gate: keyof NotifyPrefs;
92
+ text: string;
93
+ buttons?: Button[][];
94
+ }
95
+
96
+ interface AskQuestion { question: string; options: Array<{ label: string; hint?: string }>; multi?: boolean }
97
+
98
+ /**
99
+ * A pending ask the channel may answer. Kept by the hub so a tap can be
100
+ * turned back into the exact call the tab would have made.
101
+ */
102
+ interface PendingAsk {
103
+ key: string;
104
+ projectId: string;
105
+ messageId?: string;
106
+ transport?: Transport;
107
+ kind: 'perm' | 'q' | 'cq';
108
+ id: string;
109
+ /** cq only: the questions, the answers collected so far, and which is being shown. */
110
+ questions?: AskQuestion[];
111
+ answers?: Record<string, string>;
112
+ index?: number;
113
+ head?: string;
114
+ /** cq only: head and link, the text a resolution appends to. */
115
+ base?: string;
116
+ /** q only: the director's offered choices, so a tap can be turned back into its label. */
117
+ options?: string[];
118
+ }
119
+
120
+ /** Keyboard for one planner question: one option per row so long labels stay readable. */
121
+ function optionRows(askId: string, qi: number, q: AskQuestion): Button[][] {
122
+ return (q.options ?? []).slice(0, 6).map((o, oi) => [{
123
+ label: `${oi + 1}. ${clip(o.label, 40)}${oi === 0 ? ' ★' : ''}`,
124
+ data: `cq|${askId}|${qi}|${oi}`,
125
+ }]);
126
+ }
127
+
128
+ /** Text for a planner question step: which question, its options with hints, progress. */
129
+ function cqStep(head: string, qs: AskQuestion[], qi: number, answers: Record<string, string>): string {
130
+ const q = qs[qi];
131
+ const done = qs.slice(0, qi).map((p) => `✓ ${esc(clip(p.question, 60))} → <b>${esc(answers[p.question])}</b>`).join('\n');
132
+ const opts = (q.options ?? []).slice(0, 6).map((o, oi) =>
133
+ `${oi + 1}. <b>${esc(o.label)}</b>${oi === 0 ? ' ★' : ''}${o.hint ? ` — <i>${esc(clip(o.hint, 90))}</i>` : ''}`).join('\n');
134
+ return `${head}${done ? `\n${done}` : ''}\n\n${qs.length > 1 ? `<b>${qi + 1}/${qs.length}</b> · ` : ''}${esc(q.question)}\n${opts}` +
135
+ `\n<i>Tap an option, or reply with your own answer.</i>`;
136
+ }
137
+
138
+ /**
139
+ * Which events reach a human, and what they read.
140
+ *
141
+ * Kept as one function rather than a table so each case can say *why* it is
142
+ * here. Anything not listed is transcript furniture — a tool call, a token
143
+ * count, a heartbeat — and belongs on a screen someone chose to look at.
144
+ */
145
+ export function shape(env: Envelope, ctx: NotifyContext): Shaped | null {
146
+ const d = env.data ?? {};
147
+ const project = ctx.projectName?.(env.projectId) ?? env.projectId;
148
+ const link = env.chat || !env.runId
149
+ ? `${ctx.publicUrl}/#/p/${env.projectId}`
150
+ : `${ctx.publicUrl}/#/p/${env.projectId}/r/${env.runId}`;
151
+ const head = (title: string) => `<b>${esc(title)}</b> · ${esc(project)}`;
152
+ const foot = `\n<a href="${esc(link)}">Open in Foreman</a>`;
153
+ const run = env.runId ? (ctx.runLabel?.(env.runId) ?? '') : '';
154
+ const runLine = run ? `\n<i>${esc(clip(run, 90))}</i>` : '';
155
+
156
+ switch (env.event) {
157
+ // --- something needs the human -------------------------------------
158
+ case 'permission_request':
159
+ return {
160
+ key: `perm:${d.id}`, gate: 'needsYou',
161
+ text: `${head('Needs you — approval')}${runLine}\n${esc(d.agent)} wants <code>${esc(d.toolName)}</code>` +
162
+ (d.description ? `\n${esc(clip(d.description))}` : '') +
163
+ `\n<i>Auto-denied if unanswered in 10 min.</i>${foot}`,
164
+ // Allow and deny only. "Always" grants a path or a tool for the whole
165
+ // run, and that is a decision for a screen showing what it opens.
166
+ buttons: [[{ label: '✓ Allow', data: `p|${d.id}|allow` }, { label: '✗ Deny', data: `p|${d.id}|deny` }]],
167
+ };
168
+ case 'question': {
169
+ const opts = Array.isArray(d.options) ? (d.options as unknown[]).filter((o): o is string => typeof o === 'string').slice(0, 6) : [];
170
+ const list = opts.length ? '\n' + opts.map((o, i) => `${i + 1}. <b>${esc(clip(o, 80))}</b>${i === 0 ? ' ★' : ''}`).join('\n') : '';
171
+ return {
172
+ key: `q:${d.id}`, gate: 'needsYou',
173
+ text: `${head('Needs you — director asks')}${runLine}\n${esc(clip(d.question, 300))}${list}` +
174
+ `\n<i>${opts.length ? 'Tap an option, or reply with your own answer.' : 'Reply to this message to answer.'} ` +
175
+ `"Decide yourself" if unanswered in 10 min.</i>${foot}`,
176
+ buttons: opts.length
177
+ ? opts.map((o, i) => [{ label: `${i + 1}. ${clip(o, 40)}${i === 0 ? ' ★' : ''}`, data: `q|${d.id}|${i}` }])
178
+ : undefined,
179
+ };
180
+ }
181
+ case 'mission_proposed': {
182
+ // Only for a conversation the phone started: a proposal drafted at the
183
+ // desk has its card on the desk. `via` is stamped by the server.
184
+ if (d.via !== 'telegram') return null;
185
+ const done = Array.isArray(d.doneWhen) ? d.doneWhen as string[] : [];
186
+ const models = [d.directorModel, d.workerModel].filter(Boolean).join(' · ');
187
+ const lines = [
188
+ head('Mission proposed'),
189
+ `\n${esc(clip(String(d.mission ?? ''), 700))}`,
190
+ done.length ? `\n<b>Done when</b>\n${done.slice(0, 6).map((x) => `• ${esc(clip(x, 120))}`).join('\n')}${done.length > 6 ? `\n… ${done.length - 6} more` : ''}` : '',
191
+ `\n<i>cap $${Number(d.budgetUsd ?? 0)}${models ? ` · ${esc(models)}` : ''}${d.browser ? ' · browser on' : ''}</i>`,
192
+ ].filter(Boolean).join('\n');
193
+ return {
194
+ key: `proposal:${env.projectId}`, gate: 'needsYou',
195
+ text: lines + foot,
196
+ buttons: [[{ label: 'Start mission', data: `sp|${env.projectId}` }, { label: 'Discard', data: `dp|${env.projectId}` }]],
197
+ };
198
+ }
199
+ case 'chat_question': {
200
+ const qs = Array.isArray(d.questions) ? d.questions as AskQuestion[] : [];
201
+ if (!qs.length) return null;
202
+ const h = head('Needs you — the planner asks');
203
+ return {
204
+ key: `cq:${d.id}`, gate: 'needsYou',
205
+ text: cqStep(h, qs, 0, {}) + foot,
206
+ buttons: optionRows(String(d.id), 0, qs[0]),
207
+ };
208
+ }
209
+
210
+ // --- the crew is in trouble ---------------------------------------
211
+ case 'worker_stalled':
212
+ return { key: `stall:${d.id}:${env.ts ?? ''}`, gate: 'needsYou',
213
+ text: `${head('Worker stalled')}${runLine}\n${esc(clip(d.text))}${foot}` };
214
+ case 'worker_looping':
215
+ return { key: `loop:${d.id}:${env.ts ?? ''}`, gate: 'needsYou',
216
+ text: `${head('Worker looping')}${runLine}\n${esc(clip(d.text))}${foot}` };
217
+ case 'director_looping':
218
+ return { key: `dloop:${env.runId}:${env.ts ?? ''}`, gate: 'needsYou',
219
+ text: `${head('Director looping')}${runLine}\nRepeated <code>${esc(d.toolName)}</code> ${esc(d.count)}× — told to change approach.${foot}` };
220
+
221
+ // --- something was decided on the human's behalf -------------------
222
+ case 'permission_timeout':
223
+ return { key: `perm:${d.id}`, gate: 'needsYou',
224
+ text: `${head('Auto-denied (unattended)')}${runLine}\n<code>${esc(d.toolName)}</code> went unanswered; denied and redirected to the workspace.${foot}` };
225
+ case 'question_timeout':
226
+ return { key: `q:${d.id}`, gate: 'needsYou',
227
+ text: `${head('Auto-answered (unattended)')}${runLine}\nThe director was told to decide itself and record why.${foot}` };
228
+ case 'chat_question_timeout':
229
+ return { key: `cq:${d.id}`, gate: 'needsYou',
230
+ text: `${head('Planner proceeded (unattended)')}\nNo answer in 30 min; it is going with its recommendations.${foot}` };
231
+
232
+ // --- money and completion -----------------------------------------
233
+ case 'budget_alert':
234
+ return { key: `budget:${env.runId}:${d.level}`, gate: 'budget',
235
+ text: `${head(d.level === 'exceeded' ? 'Budget exceeded — run interrupted' : 'Budget cap reached')}${runLine}\n${esc(clip(d.text))}${foot}` };
236
+ case 'budget_stop':
237
+ return { key: `stop:${env.runId}`, gate: 'budget',
238
+ text: `${head('Run winding down')}${runLine}\n${esc(clip(d.reason))}${foot}` };
239
+ case 'mission_incomplete':
240
+ return { key: `incomplete:${env.runId}`, gate: 'done',
241
+ text: `${head('Not done')}${runLine}\n${esc(clip(d.text))}${foot}` };
242
+ case 'service_exposed': {
243
+ // Informational, and worth a tap: the crew put something on the air.
244
+ const url = String(d.url ?? '');
245
+ if (!url) return null;
246
+ return { key: `svc:${env.runId}:${d.port}`, gate: 'done',
247
+ text: `${head(`Service up — ${clip(String(d.label ?? 'service'), 40)}`)}${runLine}\n<a href="${esc(url)}">${esc(url)}</a>` };
248
+ }
249
+ case 'run_finished': {
250
+ const status = String(d.status ?? 'done');
251
+ const title = status === 'done' ? 'Mission done' : status === 'error' ? 'Mission failed' : 'Mission interrupted';
252
+ return { key: `finished:${env.runId}`, gate: 'done',
253
+ text: `${head(title)}${runLine}${foot}` };
254
+ }
255
+ case 'run_error':
256
+ return { key: `error:${env.runId}:${env.ts ?? ''}`, gate: 'done',
257
+ text: `${head('Mission error')}${runLine}\n${esc(clip(d.error))}${foot}` };
258
+ default:
259
+ return null;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Events that close something announced earlier. The earlier message is
265
+ * edited rather than a new one sent: a phone should show the outcome, not a
266
+ * stale question above the answer to it. Editing also drops the buttons.
267
+ */
268
+ export function resolution(env: Envelope): { key: string; suffix: string } | null {
269
+ const d = env.data ?? {};
270
+ const via = d.source ? ` · via ${esc(d.source)}` : '';
271
+ switch (env.event) {
272
+ case 'permission_resolved':
273
+ return { key: `perm:${d.id}`, suffix: `\n\n✓ ${esc(d.behavior ?? 'resolved')}${via}` };
274
+ case 'question_answered':
275
+ return { key: `q:${d.id}`, suffix: `\n\n✓ answered${via}` };
276
+ case 'chat_answered': {
277
+ const a = (d.answers ?? {}) as Record<string, string>;
278
+ const lines = Object.entries(a).map(([q, v]) => `✓ ${esc(clip(q, 60))} → <b>${esc(v)}</b>`).join('\n');
279
+ return { key: `cq:${d.id}`, suffix: `\n\n${lines || '✓ answered'}${via}` };
280
+ }
281
+ default:
282
+ return null;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * The hub: one per server. Attach transports; feed it every envelope; hand
288
+ * it the channel's taps and replies, and it hands back {@link Answer}s.
289
+ *
290
+ * Deliveries are fire-and-forget and never awaited by the emitter — a slow
291
+ * or dead channel must not slow a run. Failures are counted, not thrown.
292
+ */
293
+ export class NotifyHub {
294
+ private transports: Transport[] = [];
295
+ private recent = new Map<string, number>();
296
+ /**
297
+ * What was sent, per key. `base` is what a resolution appends to: for a
298
+ * stepped planner question it is the head and the link only, so the
299
+ * finished message reads head → answers → link, not answers under a stale
300
+ * option list from step one.
301
+ */
302
+ private sent = new Map<string, { transport: Transport; id: string; text: string; base?: string }>();
303
+ private pending = new Map<string, PendingAsk>();
304
+ private byMessage = new Map<string, string>(); // messageId -> ask key
305
+ private answerHandler: ((a: Answer) => void) | null = null;
306
+ public failures = 0;
307
+ public delivered = 0;
308
+
309
+ constructor(private ctx: () => NotifyContext) {}
310
+
311
+ attach(t: Transport): void { this.transports.push(t); }
312
+ detach(name: string): void { this.transports = this.transports.filter((t) => t.name !== name); }
313
+ get active(): string[] { return this.transports.map((t) => t.name); }
314
+
315
+ /** The server registers the one function that turns an answer into a resolve. */
316
+ onAnswer(fn: (a: Answer) => void): void { this.answerHandler = fn; }
317
+
318
+ /** Called from the broadcaster. Synchronous by design; work happens off the emitter's path. */
319
+ handle(env: Envelope): void {
320
+ if (!this.transports.length) return;
321
+ const res = resolution(env);
322
+ if (res) { void this.resolve(res.key, res.suffix); return; }
323
+ const ctx = this.ctx();
324
+ const s = shape(env, ctx);
325
+ if (!s || !ctx.prefs[s.gate]) return;
326
+ const now = env.ts ?? Date.now();
327
+ const last = this.recent.get(s.key);
328
+ // A permission that times out re-announces under the same key on purpose
329
+ // (the outcome is news); anything else repeating inside the TTL is a
330
+ // re-asking agent, and one tap is enough.
331
+ if (last && now - last < DEDUPE_TTL_MS && !/timeout$/.test(env.event)) return;
332
+ this.recent.set(s.key, now);
333
+ this.remember(env, s);
334
+ void this.deliver(s.key, s.text, s.buttons);
335
+ }
336
+
337
+ /** Keep what a tap will need, so the channel never has to know Foreman's routes. */
338
+ private remember(env: Envelope, s: Shaped): void {
339
+ const d = env.data ?? {};
340
+ if (env.event === 'permission_request') {
341
+ this.pending.set(s.key, { key: s.key, projectId: env.projectId, kind: 'perm', id: String(d.id) });
342
+ } else if (env.event === 'question') {
343
+ const options = Array.isArray(d.options) ? (d.options as unknown[]).filter((o): o is string => typeof o === 'string') : undefined;
344
+ this.pending.set(s.key, { key: s.key, projectId: env.projectId, kind: 'q', id: String(d.id), options });
345
+ } else if (env.event === 'chat_question') {
346
+ const qs = d.questions as AskQuestion[];
347
+ const c = this.ctx();
348
+ const project = c.projectName?.(env.projectId) ?? env.projectId;
349
+ const head = `<b>Needs you — the planner asks</b> · ${esc(project)}`;
350
+ const link = `${c.publicUrl}/#/p/${env.projectId}`;
351
+ this.pending.set(s.key, {
352
+ key: s.key, projectId: env.projectId, kind: 'cq', id: String(d.id),
353
+ questions: qs, answers: {}, index: 0, head,
354
+ base: `${head}\n<a href="${esc(link)}">Open in Foreman</a>`,
355
+ });
356
+ } else if (/timeout$/.test(env.event)) {
357
+ this.pending.delete(s.key);
358
+ }
359
+ }
360
+
361
+ /**
362
+ * A button was tapped. `data` is what {@link shape} put on the button;
363
+ * anything else — a stale message, a forged payload — is ignored.
364
+ */
365
+ handleCallback(data: string, messageId?: string): boolean {
366
+ const [kind, id, a, b] = data.split('|');
367
+ if (kind === 'sp' || kind === 'dp') {
368
+ this.answerHandler?.({ kind: 'proposal', projectId: id, action: kind === 'sp' ? 'start' : 'discard' });
369
+ return true;
370
+ }
371
+ if (kind === 'p' && (a === 'allow' || a === 'deny')) {
372
+ const ask = this.pending.get(`perm:${id}`);
373
+ if (!ask) return false;
374
+ // Dispatched once. The resolution event that follows still edits the
375
+ // message; a second tap on the same buttons must find nothing to do.
376
+ this.pending.delete(ask.key);
377
+ this.answerHandler?.({ kind: 'perm', id, behavior: a });
378
+ return true;
379
+ }
380
+ if (kind === 'q') {
381
+ // A director question with options: the tap is the label, exactly as
382
+ // typing it would have been.
383
+ const ask = this.pending.get(`q:${id}`);
384
+ const label = ask?.options?.[Number(a)];
385
+ if (!ask || !label) return false;
386
+ this.pending.delete(ask.key);
387
+ this.answerHandler?.({ kind: 'q', id, text: label });
388
+ return true;
389
+ }
390
+ if (kind === 'cq') {
391
+ const ask = this.pending.get(`cq:${id}`);
392
+ if (!ask || !ask.questions || ask.answers === undefined || ask.index === undefined) return false;
393
+ const qi = Number(a), oi = Number(b);
394
+ if (qi !== ask.index) return false; // a tap on an earlier step's buttons
395
+ const q = ask.questions[qi]; const opt = q?.options[oi];
396
+ if (!q || !opt) return false;
397
+ ask.answers[q.question] = opt.label;
398
+ return this.advance(ask, messageId);
399
+ }
400
+ return false;
401
+ }
402
+
403
+ /**
404
+ * Free text arrived. A reply to a known message answers that ask; otherwise
405
+ * it answers the one director question pending, or the current step of the
406
+ * one planner question pending — the same "something else" the picker has.
407
+ */
408
+ handleText(text: string, replyToMessageId?: string): boolean {
409
+ const t = text.trim();
410
+ if (!t) return false;
411
+ let ask: PendingAsk | undefined;
412
+ if (replyToMessageId) ask = this.pending.get(this.byMessage.get(replyToMessageId) ?? '');
413
+ if (!ask) {
414
+ const open = [...this.pending.values()].filter((p) => p.kind !== 'perm');
415
+ if (open.length === 1) ask = open[0];
416
+ }
417
+ if (!ask) return false;
418
+ if (ask.kind === 'q') {
419
+ this.pending.delete(ask.key);
420
+ this.answerHandler?.({ kind: 'q', id: ask.id, text: t });
421
+ return true;
422
+ }
423
+ if (ask.kind === 'cq' && ask.questions && ask.answers && ask.index !== undefined) {
424
+ ask.answers[ask.questions[ask.index].question] = t;
425
+ return this.advance(ask, ask.messageId);
426
+ }
427
+ return false;
428
+ }
429
+
430
+ /** Next planner question, or the finished answer set. */
431
+ private advance(ask: PendingAsk, messageId?: string): boolean {
432
+ const qs = ask.questions!, answers = ask.answers!;
433
+ ask.index = (ask.index ?? 0) + 1;
434
+ if (ask.index < qs.length) {
435
+ const text = cqStep(ask.head ?? '', qs, ask.index, answers);
436
+ const m = this.sent.get(ask.key);
437
+ const id = messageId ?? m?.id;
438
+ if (m && id) {
439
+ m.text = text; // so a later re-edit (timeout, dedupe) starts from the current step
440
+ void m.transport.edit(id, text, { buttons: optionRows(ask.id, ask.index, qs[ask.index]) }).catch(() => { this.failures++; });
441
+ }
442
+ return true;
443
+ }
444
+ this.pending.delete(ask.key);
445
+ this.answerHandler?.({ kind: 'cq', projectId: ask.projectId, id: ask.id, answers });
446
+ return true;
447
+ }
448
+
449
+ private async deliver(key: string, text: string, buttons?: Button[][]): Promise<void> {
450
+ for (const t of this.transports) {
451
+ try {
452
+ const existing = this.sent.get(key);
453
+ if (existing && existing.transport === t) {
454
+ // Same key again (e.g. a timeout on a pending ask): update in place.
455
+ await t.edit(existing.id, text);
456
+ existing.text = text;
457
+ } else {
458
+ const id = await t.send(text, buttons ? { buttons } : undefined);
459
+ if (id) {
460
+ this.sent.set(key, { transport: t, id, text, base: this.pending.get(key)?.base });
461
+ this.byMessage.set(id, key);
462
+ const ask = this.pending.get(key);
463
+ if (ask) { ask.messageId = id; ask.transport = t; }
464
+ }
465
+ }
466
+ this.delivered++;
467
+ } catch { this.failures++; }
468
+ }
469
+ if (this.sent.size > 500) {
470
+ for (const k of [...this.sent.keys()].slice(0, 100)) { this.sent.delete(k); this.pending.delete(k); }
471
+ }
472
+ }
473
+
474
+ private async resolve(key: string, suffix: string): Promise<void> {
475
+ this.pending.delete(key);
476
+ const m = this.sent.get(key);
477
+ if (!m) return;
478
+ // Edited without buttons: an answered question offers nothing to tap. A
479
+ // stepped question resolves onto its base — head and link — so the
480
+ // finished message is the answers, not the answers under step one's
481
+ // option list.
482
+ try { await m.transport.edit(m.id, (m.base ?? m.text) + suffix); } catch { this.failures++; }
483
+ this.sent.delete(key);
484
+ this.byMessage.delete(m.id);
485
+ }
486
+
487
+ /** Send something outside the event flow — the Settings "test" button, a command's reply, the planner's words. */
488
+ async say(text: string, opts?: { buttons?: Button[][] }): Promise<boolean> {
489
+ let ok = false;
490
+ for (const t of this.transports) {
491
+ try { if (await t.send(text, opts)) ok = true; } catch { this.failures++; }
492
+ }
493
+ return ok;
494
+ }
495
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Ollama host resolution and the zero-config provider.
3
+ *
4
+ * Model listing moved to models.test.ts when it stopped being Ollama-specific;
5
+ * what is left here is only what is genuinely about the daemon.
6
+ */
7
+ import test from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { ollamaHost, ollamaProvider } from './ollama.js';
10
+
11
+ async function withHost<T>(host: string | undefined, fn: () => Promise<T>): Promise<T> {
12
+ const saved = process.env.OLLAMA_HOST;
13
+ if (host === undefined) delete process.env.OLLAMA_HOST;
14
+ else process.env.OLLAMA_HOST = host;
15
+ try {
16
+ return await fn();
17
+ } finally {
18
+ if (saved === undefined) delete process.env.OLLAMA_HOST;
19
+ else process.env.OLLAMA_HOST = saved;
20
+ }
21
+ }
22
+
23
+ test('OLLAMA_HOST is honoured, however it is written', async () => {
24
+ await withHost(undefined, async () => assert.equal(ollamaHost(), 'http://127.0.0.1:11434'));
25
+ // The bare host:port form is the one Ollama's own docs use.
26
+ await withHost('127.0.0.1:9999', async () => assert.equal(ollamaHost(), 'http://127.0.0.1:9999'));
27
+ await withHost('http://box.local:1234/', async () => assert.equal(ollamaHost(), 'http://box.local:1234'));
28
+ await withHost('https://ollama.example', async () => assert.equal(ollamaHost(), 'https://ollama.example'));
29
+ });
30
+
31
+ test('the discovered provider needs no credential and no configuration', async () => {
32
+ await withHost('127.0.0.1:11434', async () => {
33
+ const p = ollamaProvider('qwen3:8b');
34
+ assert.equal(p.kind, 'openai-compatible');
35
+ assert.ok(!('apiKeyEnv' in p) || p.apiKeyEnv === undefined,
36
+ 'a daemon has no key — including for :cloud, which it signs for itself');
37
+ assert.equal(p.baseUrl, 'http://127.0.0.1:11434');
38
+ assert.equal(p.model, 'qwen3:8b');
39
+ });
40
+ });
41
+
42
+ test('a project can point at a daemon on another machine', async () => {
43
+ // The whole point of a per-project host: the server's own OLLAMA_HOST must
44
+ // not decide what another project talks to.
45
+ await withHost('127.0.0.1:11434', async () => {
46
+ const p = ollamaProvider('glm-5.3-flash:cloud', 'http://box.tailnet:11434');
47
+ assert.equal(p.baseUrl, 'http://box.tailnet:11434');
48
+ });
49
+ });
package/src/ollama.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Local Ollama — where it is, and how to point a project at it.
3
+ *
4
+ * Ollama serves an OpenAI-compatible `/v1`, so it is not a provider kind of
5
+ * its own: it is `openai-compatible` pointed at a daemon. Listing models is
6
+ * every endpoint's business rather than Ollama's alone, so that lives in
7
+ * models.ts; what is genuinely Ollama-specific is only this — the default
8
+ * host, and the zero-config provider for a daemon that is already running.
9
+ */
10
+ import { discoverModels, type EndpointModel } from './models.js';
11
+ import type { ProviderRef } from './types.js';
12
+
13
+ /** Where Ollama listens. Honours its own env var rather than assuming a port. */
14
+ export function ollamaHost(): string {
15
+ const raw = (process.env.OLLAMA_HOST ?? '').trim();
16
+ if (!raw) return 'http://127.0.0.1:11434';
17
+ // OLLAMA_HOST is commonly set bare ("127.0.0.1:11434", or just a host).
18
+ if (/^https?:\/\//i.test(raw)) return raw.replace(/\/+$/, '');
19
+ return `http://${raw.replace(/\/+$/, '')}`;
20
+ }
21
+
22
+ /**
23
+ * Models the server's own Ollama offers, or null if none is running.
24
+ *
25
+ * This is the *server default* view, for preflight and the instances screen.
26
+ * A project pinned to another daemon is asked about its own host — see
27
+ * discoverModels().
28
+ */
29
+ export function discoverOllama(timeoutMs = 1500): Promise<EndpointModel[] | null> {
30
+ return discoverModels(ollamaHost(), { timeoutMs });
31
+ }
32
+
33
+ /**
34
+ * The provider for an Ollama daemon.
35
+ *
36
+ * `apiKeyEnv` is deliberately absent: a daemon needs no credential — including
37
+ * for `:cloud` models, which it signs for itself with its own key — and the
38
+ * placeholder that satisfies the gateway invariant comes from
39
+ * resolveProvider() rather than being invented here.
40
+ */
41
+ export function ollamaProvider(model?: string, host?: string): Extract<ProviderRef, { kind: 'openai-compatible' }> {
42
+ return {
43
+ kind: 'openai-compatible',
44
+ id: 'ollama-local',
45
+ baseUrl: host ?? ollamaHost(),
46
+ label: 'Ollama',
47
+ model,
48
+ };
49
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The one price table Foreman keeps, and the rules that keep it honest:
3
+ * exact provenance, per-token units, and "unknown means unpriced" — never a
4
+ * near-enough guess.
5
+ */
6
+ import test from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import {
9
+ OPENAI_PRICES_VERIFIED, isOpenAiHost, openaiPrice, openaiPriceNote,
10
+ } from './openai-prices.js';
11
+ import { priceUsage } from './prices.js';
12
+
13
+ test('list prices are stored per token, straight from the per-million figures', () => {
14
+ // gpt-5: $1.25 in, $0.125 cached, $10.00 out per 1M on the verified page.
15
+ const p = openaiPrice('gpt-5')!;
16
+ assert.equal(p.input, 1.25 / 1_000_000);
17
+ assert.equal(p.cacheRead, 0.125 / 1_000_000);
18
+ assert.equal(p.output, 10 / 1_000_000);
19
+ // One million input tokens costs exactly the list price.
20
+ assert.ok(Math.abs(priceUsage(p, { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }) - 1.25) < 1e-9);
21
+ });
22
+
23
+ test('a dated snapshot resolves to its family by longest prefix, never to a sibling', () => {
24
+ assert.deepEqual(openaiPrice('gpt-5.4-2026-03-01'), openaiPrice('gpt-5.4'));
25
+ // The longer family wins: a mini snapshot is priced as mini, not as 5.4.
26
+ assert.deepEqual(openaiPrice('gpt-5.4-mini-2026-03-01'), openaiPrice('gpt-5.4-mini'));
27
+ assert.notDeepEqual(openaiPrice('gpt-5.4-mini'), openaiPrice('gpt-5.4'));
28
+ // Case and whitespace are not reasons to refuse.
29
+ assert.deepEqual(openaiPrice(' GPT-5-Nano '), openaiPrice('gpt-5-nano'));
30
+ });
31
+
32
+ test('a model the table does not know is unpriced — not the nearest neighbour', () => {
33
+ assert.equal(openaiPrice('gpt-7'), null);
34
+ assert.equal(openaiPrice('gpt-5.4x'), null, 'a prefix without the dash is a different name');
35
+ assert.equal(openaiPrice(''), null);
36
+ assert.equal(openaiPrice(undefined), null);
37
+ });
38
+
39
+ test('pro models list no cached-input rate, and the field is absent rather than zero', () => {
40
+ const p = openaiPrice('gpt-5.5-pro')!;
41
+ assert.equal('cacheRead' in p, false, 'absent means "same as input" to priceUsage; zero would mean free');
42
+ });
43
+
44
+ test('only api.openai.com is priced from this table', () => {
45
+ assert.equal(isOpenAiHost('https://api.openai.com'), true);
46
+ assert.equal(isOpenAiHost('https://api.openai.com/v1'), true);
47
+ assert.equal(isOpenAiHost('https://openrouter.ai/api'), false, 'a reseller publishes its own rates');
48
+ assert.equal(isOpenAiHost('http://127.0.0.1:11434'), false);
49
+ assert.equal(isOpenAiHost('not a url'), false);
50
+ assert.equal(isOpenAiHost(undefined), false);
51
+ });
52
+
53
+ test('the picker note names the source and the day it was checked', () => {
54
+ const note = openaiPriceNote('gpt-5.4')!;
55
+ assert.match(note, /^\$2\.50 in \/ \$15\.00 out per million tokens/);
56
+ assert.match(note, new RegExp(`verified ${OPENAI_PRICES_VERIFIED}$`));
57
+ assert.equal(openaiPriceNote('gpt-7'), null);
58
+ });