@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.
@@ -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 <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, 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