@amenophis1er/foreman 0.1.2 → 0.1.4

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
@@ -60,6 +60,19 @@ which account pays, the Claude Code install, Ollama and Codex if present, the
60
60
  browser missions will use, the port, Tailscale, the data directory — and
61
61
  exits. Nothing blocks unless it says so.
62
62
 
63
+ **Environment.** Everything the CLI reads; `foreman --help` prints the same list.
64
+
65
+ | Variable | Meaning | Default |
66
+ |---|---|---|
67
+ | `PORT` | listen port | `4177` |
68
+ | `FOREMAN_HOME` | state directory: runs, settings, logs | `~/.foreman` |
69
+ | `FOREMAN_BIND` | `auto` (loopback + Tailscale when present), `local`, or `all` | `auto` |
70
+ | `FOREMAN_BROWSER` | browser for missions: `chrome`, `chromium`, `msedge`, `firefox` | `chrome` |
71
+ | `FOREMAN_CLAUDE_CONFIG_DIR` | the Claude Code install missions run under | inherited |
72
+ | `FOREMAN_CLAUDE_EXECUTABLE` | the Claude Code executable | bundled |
73
+ | `FOREMAN_AUTH_MODE` | assert `api-key` or `subscription`; fail at start on mismatch | unset |
74
+ | `FOREMAN_NO_TELEGRAM` | `1` to start without the Telegram bot: for a second server beside the main one, since Telegram allows one poller per bot | unset |
75
+
63
76
  ## A mission, start to finish
64
77
 
65
78
  1. **Fleet.** The board: what needs you (answerable right there), what is
@@ -213,6 +226,16 @@ npm run dev # API + Vite together
213
226
  scripts/dev-restart.sh # restarts the server only when nothing would be lost
214
227
  ```
215
228
 
229
+ To run a checkout beside an installed Foreman on the same machine, give it its
230
+ own port and leave the bot to the installed one:
231
+
232
+ ```sh
233
+ PORT=4178 FOREMAN_BIND=local FOREMAN_NO_TELEGRAM=1 npm start
234
+ ```
235
+
236
+ Both read `~/.foreman`; look from the second, drive from the first — two
237
+ servers running missions against one store is the one case nothing guards.
238
+
216
239
  Releases: `npm version <patch|minor|major> && git push --follow-tags` — CI
217
240
  tests, publishes to npm with provenance, and creates the GitHub release.
218
241
 
package/bin/foreman.mjs CHANGED
@@ -25,6 +25,7 @@ const USAGE = `foreman ${pkg.version}
25
25
  foreman logs Tail the log
26
26
  foreman open Open the dashboard in your browser
27
27
  foreman doctor Check credentials, providers, browser, port, Tailscale — and exit
28
+ foreman update Install the latest version and restart the same way (refuses mid-mission; --force)
28
29
 
29
30
  foreman service install Keep Foreman running: start at login, restart if it dies
30
31
  foreman service start|stop|restart|status|logs
@@ -42,6 +43,7 @@ Environment:
42
43
  FOREMAN_CLAUDE_CONFIG_DIR Claude Code install missions run under
43
44
  FOREMAN_CLAUDE_EXECUTABLE Claude Code executable (default: bundled)
44
45
  FOREMAN_AUTH_MODE Assert 'api-key' or 'subscription'; fail on mismatch
46
+ FOREMAN_NO_TELEGRAM=1 Do not attach the Telegram bot (a second server beside the main one)
45
47
  `;
46
48
 
47
49
  const [command = 'start', ...rest] = process.argv.slice(2);
@@ -53,7 +55,7 @@ if (command === '--help' || command === '-h' || command === 'help') {
53
55
  } else if (command === 'start') {
54
56
  register();
55
57
  await import(new URL('../src/server.ts', import.meta.url).href);
56
- } else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall'].includes(command)) {
58
+ } else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall', 'update'].includes(command)) {
57
59
  register();
58
60
  const { runCli } = await import(new URL('../src/cli.ts', import.meta.url).href);
59
61
  process.exitCode = await runCli(command, rest, { version: pkg.version, bin: new URL(import.meta.url) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amenophis1er/foreman",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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
@@ -13,13 +13,14 @@
13
13
  * be up when the laptop lid is closed; this is that.
14
14
  */
15
15
  import { execFile, spawn } from 'node:child_process';
16
- import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
16
+ import { access, chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
17
17
  import { openSync } from 'node:fs';
18
18
  import os from 'node:os';
19
19
  import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { preflight, reportPreflight } from './preflight.js';
22
22
  import { detectTailscale } from './tailscale.js';
23
+ import { PACKAGE, checkForUpdate, currentVersion } from './update.js';
23
24
 
24
25
  const PORT = Number(process.env.PORT ?? 4177);
25
26
  const HOME_DIR = process.env.FOREMAN_HOME || path.join(os.homedir(), '.foreman');
@@ -169,8 +170,12 @@ async function serviceUninstall(): Promise<number> {
169
170
  /** Is the service registered, and is it running? Null pid when registered but idle. */
170
171
  async function serviceState(): Promise<{ installed: boolean; running: boolean; pid?: number }> {
171
172
  if (process.platform === 'darwin') {
173
+ // Installed means the plist is on disk; a stopped service (booted out
174
+ // by `service stop`) is still installed, and `service start` brings it
175
+ // back. launchctl only knows about loaded jobs, so it answers "running".
176
+ const installed = await access(path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`)).then(() => true, () => false);
172
177
  const r = await sh('launchctl', ['print', `gui/${os.userInfo().uid}/${LABEL}`]);
173
- if (r.code !== 0) return { installed: false, running: false };
178
+ if (r.code !== 0) return { installed, running: false };
174
179
  const pid = Number(/pid = (\d+)/.exec(r.out)?.[1]);
175
180
  return { installed: true, running: Number.isFinite(pid) && pid > 0, ...(pid ? { pid } : {}) };
176
181
  }
@@ -229,7 +234,11 @@ async function serviceRestart(): Promise<number> {
229
234
  async function serviceStatus(): Promise<number> {
230
235
  if (process.platform === 'darwin') {
231
236
  const r = await sh('launchctl', ['print', `gui/${os.userInfo().uid}/${LABEL}`]);
232
- if (r.code !== 0) { console.log('Not installed. `foreman service install` keeps Foreman running.'); return 1; }
237
+ if (r.code !== 0) {
238
+ const st = await serviceState();
239
+ console.log(st.installed ? 'Installed · stopped — `foreman service start` starts it (it also comes back at next login).' : 'Not installed. `foreman service install` keeps Foreman running.');
240
+ return 1;
241
+ }
233
242
  const state = /state = (\w+)/.exec(r.out)?.[1] ?? 'unknown';
234
243
  const pid = /pid = (\d+)/.exec(r.out)?.[1];
235
244
  console.log(`Installed · ${state}${pid ? ` · pid ${pid}` : ''} · http://localhost:${PORT}`);
@@ -253,6 +262,62 @@ async function serviceLogs(): Promise<number> {
253
262
  return new Promise((r) => child.on('exit', (c) => r(c ?? 0)));
254
263
  }
255
264
 
265
+ /** One quiet line when a newer version exists; nothing when current or unknown. */
266
+ async function updateHint(): Promise<void> {
267
+ const u = await checkForUpdate(currentVersion(), 2_000);
268
+ if (u?.newer) console.log(`\nForeman ${u.latest} is available (you have ${u.current}) — \`foreman update\``);
269
+ }
270
+
271
+ /** Anything a restart would cut off: a run, an ask waiting, a planner mid-reply. */
272
+ async function liveWork(): Promise<string | null> {
273
+ try {
274
+ const d = await fetch(`http://127.0.0.1:${PORT}/projects`, { signal: AbortSignal.timeout(2000) }).then((r) => r.json()) as { projects: Array<{ id: string; name: string; activeRun?: unknown; needs?: unknown[] }> };
275
+ const running = d.projects.filter((p) => p.activeRun).map((p) => p.name);
276
+ const needs = d.projects.reduce((n, p) => n + (p.needs?.length ?? 0), 0);
277
+ const thinking: string[] = [];
278
+ for (const p of d.projects) {
279
+ try {
280
+ const c = await fetch(`http://127.0.0.1:${PORT}/chat?projectId=${encodeURIComponent(p.id)}`, { signal: AbortSignal.timeout(2000) }).then((r) => r.json()) as { thinking?: boolean };
281
+ if (c.thinking) thinking.push(p.name);
282
+ } catch { /* a project whose chat cannot be read is not live work */ }
283
+ }
284
+ if (!running.length && !needs && !thinking.length) return null;
285
+ return [running.length ? `running: ${running.join(', ')}` : '', needs ? `${needs} ask(s) waiting` : '', thinking.length ? `planner replying: ${thinking.join(', ')}` : ''].filter(Boolean).join(' · ');
286
+ } catch { return null; } // not up — nothing to cut off
287
+ }
288
+
289
+ /**
290
+ * Update the installed package and restart Foreman the way it is running.
291
+ * Refuses while anything would be cut off; --force overrides, eyes open.
292
+ * Never automatic: this is the one place the code under a mission changes,
293
+ * and it happens by a human's hand.
294
+ */
295
+ async function update(bin: string, flags: string[]): Promise<number> {
296
+ const u = await checkForUpdate(currentVersion(), 5_000);
297
+ if (!u) { console.error('Could not reach the npm registry to check for a newer version.'); return 1; }
298
+ if (!u.newer) { console.log(`Already on the latest version (${u.current}).`); return 0; }
299
+ const busy = await liveWork();
300
+ if (busy && !flags.includes('--force')) {
301
+ console.error(`Not updating: ${busy}. An update restarts the server and would cut that off. Wait, or \`foreman update --force\`.`);
302
+ return 2;
303
+ }
304
+ console.log(`Updating ${u.current} → ${u.latest}…`);
305
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
306
+ const code = await new Promise<number>((r) => {
307
+ const c = spawn(npm, ['install', '-g', `${PACKAGE}@${u.latest}`, '--no-audit', '--no-fund'], { stdio: 'inherit' });
308
+ c.on('exit', (x) => r(x ?? 1)); c.on('error', () => r(1));
309
+ });
310
+ if (code !== 0) { console.error('npm install failed; nothing was restarted.'); return code; }
311
+ // Restart by the means it is running, so the new code actually serves.
312
+ const pid = await readPid();
313
+ if (pid && alive(pid)) { console.log('Restarting the background server…'); const c = await down(); if (c !== 0) return c; return up(bin); }
314
+ const svc = await serviceState();
315
+ if (svc.running || svc.installed) { console.log('Restarting the service…'); return serviceRestart(); }
316
+ if (await listening(PORT)) { console.log(`Installed ${u.latest}. The server on :${PORT} runs in a terminal — restart it there to pick it up.`); return 0; }
317
+ console.log(`Installed ${u.latest}. Start it with \`foreman\`, \`foreman up\` or \`foreman service install\`.`);
318
+ return 0;
319
+ }
320
+
256
321
  async function doctor(): Promise<number> {
257
322
  const tailnet = await detectTailscale();
258
323
  const distDir = fileURLToPath(new URL('../ui/dist', import.meta.url));
@@ -262,6 +327,7 @@ async function doctor(): Promise<number> {
262
327
  if (c.name.startsWith('Port') && c.status === 'error') { c.status = 'warn'; c.detail = 'in use — Foreman is probably already running'; c.fix = `foreman open · or PORT=${PORT + 1} foreman`; }
263
328
  }
264
329
  const ok = reportPreflight(checks);
330
+ await updateHint();
265
331
  return ok ? 0 : 1;
266
332
  }
267
333
 
@@ -342,6 +408,7 @@ async function status(): Promise<number> {
342
408
  const svc = await serviceState();
343
409
  const how = pid && alive(pid) ? `background, pid ${pid}` : svc.running ? `the service${svc.pid ? `, pid ${svc.pid}` : ''}` : 'a terminal';
344
410
  console.log(`Up at http://localhost:${PORT} (${how})`);
411
+ await updateHint();
345
412
  return 0;
346
413
  }
347
414
  if (pid) await rm(PID_FILE, { force: true });
@@ -419,6 +486,7 @@ export async function runCli(command: string, rest: string[], ctx: { version: st
419
486
  switch (command) {
420
487
  case 'up': return up(bin);
421
488
  case 'down': return down();
489
+ case 'update': return update(bin, rest);
422
490
  case 'stop': return stop();
423
491
  case 'restart': return restart(bin);
424
492
  case 'logs': return logs();
package/src/server.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  import { DEFAULT_TOOL_POLICY } from './policy.js';
56
56
  import { saveAttachments } from './attachments.js';
57
57
  import { detectTailscale, tailnetUrl } from './tailscale.js';
58
+ import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
58
59
  import { ServiceRegistry, SVC_PREFIX, parseServicePath, portOpen, proxyToService, servicePath } from './services.js';
59
60
  import { HELP_TEXT, parseCommand, projectsRoot, slug } from './notify/commands.js';
60
61
  import { escapeHtml as escTg } from './notify.js';
@@ -421,6 +422,15 @@ const BIND = (process.env.FOREMAN_BIND ?? 'auto') as 'auto' | 'all' | 'local';
421
422
  const tailnet = BIND === 'local' ? null : await detectTailscale();
422
423
  /** Dev servers the crew put behind /svc/ — see services.ts. */
423
424
  const services = new ServiceRegistry();
425
+ /**
426
+ * Whether a newer Foreman exists, for the header's quiet pill. Checked at
427
+ * start and every six hours, never acted on: updating is `foreman update`,
428
+ * by hand, and never under a running mission.
429
+ */
430
+ let updateInfo: UpdateInfo | null = null;
431
+ const refreshUpdateInfo = () => { void checkForUpdate(currentVersion(), 4_000).then((u) => { updateInfo = u; }); };
432
+ refreshUpdateInfo();
433
+ setInterval(refreshUpdateInfo, 6 * 60 * 60_000).unref();
424
434
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
425
435
  const DIST_DIR = path.join(__dirname, '..', 'ui', 'dist');
426
436
  const ROOT_ASSETS = new Set(['/favicon.svg']);
@@ -598,6 +608,11 @@ let telegramBot: TelegramBot | null = null;
598
608
  /** (Re)build the Telegram side from the stored token and linked chat. */
599
609
  async function reattachTelegram(): Promise<boolean> {
600
610
  notifyHub.detach('telegram');
611
+ // A second server beside the installed one (a dev checkout on another
612
+ // port) must not start a second poller: Telegram allows one per bot, and
613
+ // two fight over getUpdates. FOREMAN_NO_TELEGRAM=1 leaves the channel to
614
+ // whichever server does not set it.
615
+ if (process.env.FOREMAN_NO_TELEGRAM === '1') return false;
601
616
  const s = await notifySettings();
602
617
  const token = await getSecret(store.root, 'telegram');
603
618
  if (!token) { void telegramBot?.stop(); telegramBot = null; return false; }
@@ -1338,6 +1353,7 @@ const server = http.createServer(async (req, res) => {
1338
1353
  providerHasKey: await providerHasKeyOf(p),
1339
1354
  activeRun: run ? { ...run.meta } : null,
1340
1355
  lastRun: lastRun && {
1356
+ id: lastRun.id,
1341
1357
  mission: lastRun.mission, title: lastRun.title, status: lastRun.status,
1342
1358
  createdAt: lastRun.createdAt, costUsd: lastRun.costUsd,
1343
1359
  // The card may print a dollar only where the dollar was real.
@@ -1377,6 +1393,8 @@ const server = http.createServer(async (req, res) => {
1377
1393
  authMode: auth.mode,
1378
1394
  authSource: auth.source,
1379
1395
  authAccount: auth.account ?? null,
1396
+ version: currentVersion(),
1397
+ update: updateInfo?.newer ? { latest: updateInfo.latest } : null,
1380
1398
  projects: cards.sort(fleetOrder),
1381
1399
  });
1382
1400
 
@@ -0,0 +1,27 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import { compareVersions, latestVersion } from './update.js';
5
+
6
+ test('compareVersions: numeric, not lexical; pre-release below release', () => {
7
+ assert.equal(compareVersions('0.1.2', '0.1.10'), -1);
8
+ assert.equal(compareVersions('1.0.0', '0.9.9'), 1);
9
+ assert.equal(compareVersions('v0.1.2', '0.1.2'), 0);
10
+ assert.equal(compareVersions('1.0.0-beta.1', '1.0.0'), -1);
11
+ assert.equal(compareVersions('1.0.0', '1.0.0-rc.1'), 1);
12
+ assert.equal(compareVersions('0.2', '0.2.0'), 0);
13
+ });
14
+
15
+ test('latestVersion: reads the latest tag; a silent registry is null, not an error', async () => {
16
+ const srv = http.createServer((req, res) => {
17
+ if (req.url === '/@x%2Fy/latest') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ version: '9.9.9' })); }
18
+ else { res.writeHead(404); res.end(); }
19
+ });
20
+ await new Promise<void>((r) => srv.listen(0, '127.0.0.1', r));
21
+ const port = (srv.address() as { port: number }).port;
22
+ try {
23
+ assert.equal(await latestVersion('@x/y', 2000, `http://127.0.0.1:${port}`), '9.9.9');
24
+ assert.equal(await latestVersion('@x/missing', 2000, `http://127.0.0.1:${port}/nope`), null);
25
+ } finally { srv.close(); }
26
+ assert.equal(await latestVersion('@x/y', 200, 'http://127.0.0.1:9'), null);
27
+ });
package/src/update.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * "Is there a newer Foreman?" — asked of the npm registry, answered quietly.
3
+ *
4
+ * Read-only and best-effort: a registry that does not answer within the
5
+ * timeout means "unknown", never an error, and nothing here ever installs
6
+ * anything. Installing is `foreman update`'s job, on purpose and by hand;
7
+ * Foreman runs agents on your files while you are away, and it must never
8
+ * change under a running mission without a human's hand.
9
+ */
10
+ import { readFileSync } from 'node:fs';
11
+
12
+ export const PACKAGE = '@amenophis1er/foreman';
13
+
14
+ export interface UpdateInfo {
15
+ current: string;
16
+ latest: string;
17
+ /** `latest` is strictly newer than `current`. */
18
+ newer: boolean;
19
+ }
20
+
21
+ /** `1.2.3` vs `1.10.0` the numeric way; a pre-release tag sorts below its release. */
22
+ export function compareVersions(a: string, b: string): number {
23
+ const parse = (v: string) => {
24
+ const [core, pre] = v.replace(/^v/, '').split('-', 2);
25
+ return { nums: core.split('.').map((n) => parseInt(n, 10) || 0), pre: pre ?? '' };
26
+ };
27
+ const A = parse(a), B = parse(b);
28
+ for (let i = 0; i < 3; i++) {
29
+ const d = (A.nums[i] ?? 0) - (B.nums[i] ?? 0);
30
+ if (d !== 0) return d < 0 ? -1 : 1;
31
+ }
32
+ if (A.pre === B.pre) return 0;
33
+ if (!A.pre) return 1; // release > pre-release
34
+ if (!B.pre) return -1;
35
+ return A.pre < B.pre ? -1 : 1;
36
+ }
37
+
38
+ /** The version of the package this code runs from. */
39
+ export function currentVersion(pkgUrl = new URL('../package.json', import.meta.url)): string {
40
+ try { return String(JSON.parse(readFileSync(pkgUrl, 'utf8')).version ?? '0.0.0'); } catch { return '0.0.0'; }
41
+ }
42
+
43
+ /** The registry's `latest` tag, or null when it cannot be reached in time. */
44
+ export async function latestVersion(pkg = PACKAGE, timeoutMs = 2_500, registry = 'https://registry.npmjs.org'): Promise<string | null> {
45
+ try {
46
+ const r = await fetch(`${registry}/${encodeURIComponent(pkg).replace('%40', '@')}/latest`, {
47
+ signal: AbortSignal.timeout(timeoutMs), headers: { accept: 'application/json' },
48
+ });
49
+ if (!r.ok) return null;
50
+ const d = await r.json() as { version?: string };
51
+ return typeof d.version === 'string' ? d.version : null;
52
+ } catch { return null; }
53
+ }
54
+
55
+ /** Current vs latest, or null when the registry did not answer. */
56
+ export async function checkForUpdate(current = currentVersion(), timeoutMs?: number): Promise<UpdateInfo | null> {
57
+ const latest = await latestVersion(PACKAGE, timeoutMs);
58
+ if (!latest) return null;
59
+ return { current, latest, newer: compareVersions(latest, current) > 0 };
60
+ }