@amenophis1er/foreman 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/foreman.mjs +2 -1
- package/package.json +1 -1
- package/src/cli.ts +14 -1
- package/src/completion.test.ts +30 -0
- package/src/completion.ts +113 -0
- 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 +428 -24
- package/src/store.ts +6 -2
- package/src/tailscale.test.ts +27 -1
- package/src/tailscale.ts +58 -4
- package/ui/dist/assets/index-BPFc7O5V.js +67 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-D7mHXjSp.js +0 -67
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
|
|