@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 +2 -1
- package/package.json +2 -2
- package/src/cli.ts +60 -0
- package/src/server.ts +46 -8
- package/src/update.test.ts +27 -0
- package/src/update.ts +60 -0
- package/ui/dist/assets/index-oXWBvBdD.js +66 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-D5SGIlnX.js +0 -66
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.
|
|
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": "
|
|
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
|
-
/**
|
|
1135
|
-
|
|
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
|
|
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
|
-
|
|
1147
|
-
if (
|
|
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
|
-
|
|
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
|
+
}
|