@amenophis1er/foreman 0.1.1 → 0.1.3

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 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
@@ -53,7 +54,7 @@ if (command === '--help' || command === '-h' || command === 'help') {
53
54
  } else if (command === 'start') {
54
55
  register();
55
56
  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)) {
57
+ } else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall', 'update'].includes(command)) {
57
58
  register();
58
59
  const { runCli } = await import(new URL('../src/cli.ts', import.meta.url).href);
59
60
  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.1",
3
+ "version": "0.1.3",
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",
@@ -24,7 +24,7 @@
24
24
  "author": "Amen AMOUZOU",
25
25
  "type": "module",
26
26
  "bin": {
27
- "foreman": "./bin/foreman.mjs"
27
+ "foreman": "bin/foreman.mjs"
28
28
  },
29
29
  "files": [
30
30
  "bin",
package/src/cli.ts CHANGED
@@ -20,6 +20,7 @@ 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');
@@ -253,6 +254,62 @@ async function serviceLogs(): Promise<number> {
253
254
  return new Promise((r) => child.on('exit', (c) => r(c ?? 0)));
254
255
  }
255
256
 
257
+ /** One quiet line when a newer version exists; nothing when current or unknown. */
258
+ async function updateHint(): Promise<void> {
259
+ const u = await checkForUpdate(currentVersion(), 2_000);
260
+ if (u?.newer) console.log(`\nForeman ${u.latest} is available (you have ${u.current}) — \`foreman update\``);
261
+ }
262
+
263
+ /** Anything a restart would cut off: a run, an ask waiting, a planner mid-reply. */
264
+ async function liveWork(): Promise<string | null> {
265
+ try {
266
+ 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[] }> };
267
+ const running = d.projects.filter((p) => p.activeRun).map((p) => p.name);
268
+ const needs = d.projects.reduce((n, p) => n + (p.needs?.length ?? 0), 0);
269
+ const thinking: string[] = [];
270
+ for (const p of d.projects) {
271
+ try {
272
+ 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 };
273
+ if (c.thinking) thinking.push(p.name);
274
+ } catch { /* a project whose chat cannot be read is not live work */ }
275
+ }
276
+ if (!running.length && !needs && !thinking.length) return null;
277
+ return [running.length ? `running: ${running.join(', ')}` : '', needs ? `${needs} ask(s) waiting` : '', thinking.length ? `planner replying: ${thinking.join(', ')}` : ''].filter(Boolean).join(' · ');
278
+ } catch { return null; } // not up — nothing to cut off
279
+ }
280
+
281
+ /**
282
+ * Update the installed package and restart Foreman the way it is running.
283
+ * Refuses while anything would be cut off; --force overrides, eyes open.
284
+ * Never automatic: this is the one place the code under a mission changes,
285
+ * and it happens by a human's hand.
286
+ */
287
+ async function update(bin: string, flags: string[]): Promise<number> {
288
+ const u = await checkForUpdate(currentVersion(), 5_000);
289
+ if (!u) { console.error('Could not reach the npm registry to check for a newer version.'); return 1; }
290
+ if (!u.newer) { console.log(`Already on the latest version (${u.current}).`); return 0; }
291
+ const busy = await liveWork();
292
+ if (busy && !flags.includes('--force')) {
293
+ console.error(`Not updating: ${busy}. An update restarts the server and would cut that off. Wait, or \`foreman update --force\`.`);
294
+ return 2;
295
+ }
296
+ console.log(`Updating ${u.current} → ${u.latest}…`);
297
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
298
+ const code = await new Promise<number>((r) => {
299
+ const c = spawn(npm, ['install', '-g', `${PACKAGE}@${u.latest}`, '--no-audit', '--no-fund'], { stdio: 'inherit' });
300
+ c.on('exit', (x) => r(x ?? 1)); c.on('error', () => r(1));
301
+ });
302
+ if (code !== 0) { console.error('npm install failed; nothing was restarted.'); return code; }
303
+ // Restart by the means it is running, so the new code actually serves.
304
+ const pid = await readPid();
305
+ if (pid && alive(pid)) { console.log('Restarting the background server…'); const c = await down(); if (c !== 0) return c; return up(bin); }
306
+ const svc = await serviceState();
307
+ if (svc.running || svc.installed) { console.log('Restarting the service…'); return serviceRestart(); }
308
+ 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; }
309
+ console.log(`Installed ${u.latest}. Start it with \`foreman\`, \`foreman up\` or \`foreman service install\`.`);
310
+ return 0;
311
+ }
312
+
256
313
  async function doctor(): Promise<number> {
257
314
  const tailnet = await detectTailscale();
258
315
  const distDir = fileURLToPath(new URL('../ui/dist', import.meta.url));
@@ -262,6 +319,7 @@ async function doctor(): Promise<number> {
262
319
  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
320
  }
264
321
  const ok = reportPreflight(checks);
322
+ await updateHint();
265
323
  return ok ? 0 : 1;
266
324
  }
267
325
 
@@ -342,6 +400,7 @@ async function status(): Promise<number> {
342
400
  const svc = await serviceState();
343
401
  const how = pid && alive(pid) ? `background, pid ${pid}` : svc.running ? `the service${svc.pid ? `, pid ${svc.pid}` : ''}` : 'a terminal';
344
402
  console.log(`Up at http://localhost:${PORT} (${how})`);
403
+ await updateHint();
345
404
  return 0;
346
405
  }
347
406
  if (pid) await rm(PID_FILE, { force: true });
@@ -419,6 +478,7 @@ export async function runCli(command: string, rest: string[], ctx: { version: st
419
478
  switch (command) {
420
479
  case 'up': return up(bin);
421
480
  case 'down': return down();
481
+ case 'update': return update(bin, rest);
422
482
  case 'stop': return stop();
423
483
  case 'restart': return restart(bin);
424
484
  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']);
@@ -1131,20 +1141,39 @@ async function startRun(
1131
1141
  await driveRun(projectId, meta);
1132
1142
  }
1133
1143
 
1134
- /** Resumes an interrupted run by restoring the director's session. */
1135
- async function resumeRun(projectId: string, meta: RunMeta): Promise<void> {
1144
+ /**
1145
+ * Resumes an interrupted run by restoring the director's session. `pick`
1146
+ * carries models chosen for this resume (the header's "Resume on…"); a role
1147
+ * it names wins over Settings for that role.
1148
+ */
1149
+ async function resumeRun(projectId: string, meta: RunMeta, pick: {
1150
+ directorModel?: string; directorProviderId?: string; workerModel?: string; workerProviderId?: string;
1151
+ } = {}): Promise<void> {
1136
1152
  const sessionId = meta.directorSessionId;
1137
1153
  // Resume re-reads Settings, so changing models or tool policy after a
1138
1154
  // failure takes effect on the retry. A director session cannot switch
1139
1155
  // model mid-session, so a changed director model restarts the session
1140
1156
  // fresh (the mission doc carries the state forward).
1141
- const settings = await effectiveSettings(projectId);
1157
+ const base = await effectiveSettings(projectId);
1158
+ const settings = {
1159
+ ...base,
1160
+ ...(pick.directorModel ? { directorModel: modelChoice(pick.directorModel), directorProviderId: pick.directorProviderId } : {}),
1161
+ ...(pick.workerModel ? { workerModel: modelChoice(pick.workerModel), workerProviderId: pick.workerProviderId } : {}),
1162
+ };
1163
+ // A model is picked together with the provider that serves it, so a
1164
+ // change of either moves the role. Without the provider following the
1165
+ // model, "resume on Sonnet" after a Codex usage limit went back through
1166
+ // the Codex gateway — which remapped the unknown id to its own default and
1167
+ // hit the same 429. The provider id is left undefined when Settings does
1168
+ // not pin one, which means the project's own provider, as at start.
1142
1169
  const directorChanged =
1143
- settings.directorModel !== undefined && settings.directorModel !== meta.directorModel;
1170
+ (settings.directorModel !== undefined && settings.directorModel !== meta.directorModel)
1171
+ || (settings.directorModel !== undefined && settings.directorProviderId !== meta.directorProviderId);
1144
1172
  const workerChanged =
1145
- settings.workerModel !== undefined && settings.workerModel !== meta.workerModel;
1146
- if (directorChanged) meta.directorModel = settings.directorModel;
1147
- if (workerChanged) meta.workerModel = settings.workerModel;
1173
+ (settings.workerModel !== undefined && settings.workerModel !== meta.workerModel)
1174
+ || (settings.workerModel !== undefined && settings.workerProviderId !== meta.workerProviderId);
1175
+ if (directorChanged) { meta.directorModel = settings.directorModel; meta.directorProviderId = settings.directorProviderId; }
1176
+ if (workerChanged) { meta.workerModel = settings.workerModel; meta.workerProviderId = settings.workerProviderId; }
1148
1177
  meta.toolPolicy = settings.toolPolicy;
1149
1178
  meta.autoAllowReadOnly = settings.autoAllowReadOnly;
1150
1179
  meta.status = 'running';
@@ -1358,6 +1387,8 @@ const server = http.createServer(async (req, res) => {
1358
1387
  authMode: auth.mode,
1359
1388
  authSource: auth.source,
1360
1389
  authAccount: auth.account ?? null,
1390
+ version: currentVersion(),
1391
+ update: updateInfo?.newer ? { latest: updateInfo.latest } : null,
1361
1392
  projects: cards.sort(fleetOrder),
1362
1393
  });
1363
1394
 
@@ -1863,7 +1894,14 @@ const server = http.createServer(async (req, res) => {
1863
1894
  if (!reserveProject(meta.projectId)) {
1864
1895
  return json(res, 409, { error: 'this project already has an active mission' });
1865
1896
  }
1866
- void resumeRun(meta.projectId, meta);
1897
+ // "Resume on…": models picked for this resume, ahead of Settings. A
1898
+ // model without a provider id means the project's own provider.
1899
+ const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
1900
+ const overrides = {
1901
+ directorModel: str(resumeBody.directorModel), directorProviderId: str(resumeBody.directorProviderId),
1902
+ workerModel: str(resumeBody.workerModel), workerProviderId: str(resumeBody.workerProviderId),
1903
+ };
1904
+ void resumeRun(meta.projectId, meta, overrides);
1867
1905
  json(res, 200, { ok: true });
1868
1906
 
1869
1907
  } else if (req.method === 'GET' && runEventsMatch) {
@@ -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
+ }