@amenophis1er/foreman 0.1.3 → 0.1.5
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 +27 -0
- package/bin/foreman.mjs +1 -0
- package/package.json +1 -1
- package/src/cli.ts +11 -3
- package/src/server.ts +26 -24
- package/ui/dist/assets/index-D7mHXjSp.js +67 -0
- package/ui/dist/assets/index-tY7na0em.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-LAj0Dy9p.css +0 -1
- package/ui/dist/assets/index-oXWBvBdD.js +0 -66
package/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/brand/logo.svg" alt="" width="72" height="72">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
# Foreman
|
|
2
6
|
|
|
3
7
|
Foreman runs software missions without you in the loop, and shows you
|
|
@@ -60,6 +64,19 @@ which account pays, the Claude Code install, Ollama and Codex if present, the
|
|
|
60
64
|
browser missions will use, the port, Tailscale, the data directory — and
|
|
61
65
|
exits. Nothing blocks unless it says so.
|
|
62
66
|
|
|
67
|
+
**Environment.** Everything the CLI reads; `foreman --help` prints the same list.
|
|
68
|
+
|
|
69
|
+
| Variable | Meaning | Default |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `PORT` | listen port | `4177` |
|
|
72
|
+
| `FOREMAN_HOME` | state directory: runs, settings, logs | `~/.foreman` |
|
|
73
|
+
| `FOREMAN_BIND` | `auto` (loopback + Tailscale when present), `local`, or `all` | `auto` |
|
|
74
|
+
| `FOREMAN_BROWSER` | browser for missions: `chrome`, `chromium`, `msedge`, `firefox` | `chrome` |
|
|
75
|
+
| `FOREMAN_CLAUDE_CONFIG_DIR` | the Claude Code install missions run under | inherited |
|
|
76
|
+
| `FOREMAN_CLAUDE_EXECUTABLE` | the Claude Code executable | bundled |
|
|
77
|
+
| `FOREMAN_AUTH_MODE` | assert `api-key` or `subscription`; fail at start on mismatch | unset |
|
|
78
|
+
| `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 |
|
|
79
|
+
|
|
63
80
|
## A mission, start to finish
|
|
64
81
|
|
|
65
82
|
1. **Fleet.** The board: what needs you (answerable right there), what is
|
|
@@ -213,6 +230,16 @@ npm run dev # API + Vite together
|
|
|
213
230
|
scripts/dev-restart.sh # restarts the server only when nothing would be lost
|
|
214
231
|
```
|
|
215
232
|
|
|
233
|
+
To run a checkout beside an installed Foreman on the same machine, give it its
|
|
234
|
+
own port and leave the bot to the installed one:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
PORT=4178 FOREMAN_BIND=local FOREMAN_NO_TELEGRAM=1 npm start
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Both read `~/.foreman`; look from the second, drive from the first — two
|
|
241
|
+
servers running missions against one store is the one case nothing guards.
|
|
242
|
+
|
|
216
243
|
Releases: `npm version <patch|minor|major> && git push --follow-tags` — CI
|
|
217
244
|
tests, publishes to npm with provenance, and creates the GitHub release.
|
|
218
245
|
|
package/bin/foreman.mjs
CHANGED
|
@@ -43,6 +43,7 @@ Environment:
|
|
|
43
43
|
FOREMAN_CLAUDE_CONFIG_DIR Claude Code install missions run under
|
|
44
44
|
FOREMAN_CLAUDE_EXECUTABLE Claude Code executable (default: bundled)
|
|
45
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)
|
|
46
47
|
`;
|
|
47
48
|
|
|
48
49
|
const [command = 'start', ...rest] = process.argv.slice(2);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amenophis1er/foreman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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,7 +13,7 @@
|
|
|
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';
|
|
@@ -170,8 +170,12 @@ async function serviceUninstall(): Promise<number> {
|
|
|
170
170
|
/** Is the service registered, and is it running? Null pid when registered but idle. */
|
|
171
171
|
async function serviceState(): Promise<{ installed: boolean; running: boolean; pid?: number }> {
|
|
172
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);
|
|
173
177
|
const r = await sh('launchctl', ['print', `gui/${os.userInfo().uid}/${LABEL}`]);
|
|
174
|
-
if (r.code !== 0) return { installed
|
|
178
|
+
if (r.code !== 0) return { installed, running: false };
|
|
175
179
|
const pid = Number(/pid = (\d+)/.exec(r.out)?.[1]);
|
|
176
180
|
return { installed: true, running: Number.isFinite(pid) && pid > 0, ...(pid ? { pid } : {}) };
|
|
177
181
|
}
|
|
@@ -230,7 +234,11 @@ async function serviceRestart(): Promise<number> {
|
|
|
230
234
|
async function serviceStatus(): Promise<number> {
|
|
231
235
|
if (process.platform === 'darwin') {
|
|
232
236
|
const r = await sh('launchctl', ['print', `gui/${os.userInfo().uid}/${LABEL}`]);
|
|
233
|
-
if (r.code !== 0) {
|
|
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
|
+
}
|
|
234
242
|
const state = /state = (\w+)/.exec(r.out)?.[1] ?? 'unknown';
|
|
235
243
|
const pid = /pid = (\d+)/.exec(r.out)?.[1];
|
|
236
244
|
console.log(`Installed · ${state}${pid ? ` · pid ${pid}` : ''} · http://localhost:${PORT}`);
|
package/src/server.ts
CHANGED
|
@@ -608,6 +608,11 @@ let telegramBot: TelegramBot | null = null;
|
|
|
608
608
|
/** (Re)build the Telegram side from the stored token and linked chat. */
|
|
609
609
|
async function reattachTelegram(): Promise<boolean> {
|
|
610
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;
|
|
611
616
|
const s = await notifySettings();
|
|
612
617
|
const token = await getSecret(store.root, 'telegram');
|
|
613
618
|
if (!token) { void telegramBot?.stop(); telegramBot = null; return false; }
|
|
@@ -1150,30 +1155,26 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1150
1155
|
directorModel?: string; directorProviderId?: string; workerModel?: string; workerProviderId?: string;
|
|
1151
1156
|
} = {}): Promise<void> {
|
|
1152
1157
|
const sessionId = meta.directorSessionId;
|
|
1153
|
-
// Resume re-reads Settings
|
|
1154
|
-
// failure takes effect on the retry.
|
|
1155
|
-
//
|
|
1156
|
-
// fresh (the mission doc carries the state forward).
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
const
|
|
1170
|
-
(
|
|
1171
|
-
|
|
1172
|
-
|
|
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; }
|
|
1158
|
+
// Resume re-reads Settings for tool policy and auto-allow, so a policy
|
|
1159
|
+
// change after a failure takes effect on the retry. Models are the run's
|
|
1160
|
+
// own unless "Resume on…" says otherwise; a changed director model then
|
|
1161
|
+
// restarts the session fresh (the mission doc carries the state forward).
|
|
1162
|
+
const settings = await effectiveSettings(projectId);
|
|
1163
|
+
// A plain Resume keeps the run's own models. It used to re-read the
|
|
1164
|
+
// project's Settings and treat any difference as "the human changed the
|
|
1165
|
+
// model" — but a run whose models were chosen at start (a local model
|
|
1166
|
+
// picked on the card) differs from Settings by construction, and one
|
|
1167
|
+
// Resume silently handed a 9B local-model test to Fable and Opus, at $4.82,
|
|
1168
|
+
// and called the result the 9B's. Changing models on resume is now only
|
|
1169
|
+
// ever explicit: "Resume on…" passes `pick`. A model is picked together
|
|
1170
|
+
// with the provider that serves it; a pick without a provider id means the
|
|
1171
|
+
// project's own provider, as at start.
|
|
1172
|
+
const directorChanged = Boolean(pick.directorModel) && (
|
|
1173
|
+
modelChoice(pick.directorModel) !== meta.directorModel || pick.directorProviderId !== meta.directorProviderId);
|
|
1174
|
+
const workerChanged = Boolean(pick.workerModel) && (
|
|
1175
|
+
modelChoice(pick.workerModel) !== meta.workerModel || pick.workerProviderId !== meta.workerProviderId);
|
|
1176
|
+
if (directorChanged) { meta.directorModel = modelChoice(pick.directorModel); meta.directorProviderId = pick.directorProviderId; }
|
|
1177
|
+
if (workerChanged) { meta.workerModel = modelChoice(pick.workerModel); meta.workerProviderId = pick.workerProviderId; }
|
|
1177
1178
|
meta.toolPolicy = settings.toolPolicy;
|
|
1178
1179
|
meta.autoAllowReadOnly = settings.autoAllowReadOnly;
|
|
1179
1180
|
meta.status = 'running';
|
|
@@ -1348,6 +1349,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1348
1349
|
providerHasKey: await providerHasKeyOf(p),
|
|
1349
1350
|
activeRun: run ? { ...run.meta } : null,
|
|
1350
1351
|
lastRun: lastRun && {
|
|
1352
|
+
id: lastRun.id,
|
|
1351
1353
|
mission: lastRun.mission, title: lastRun.title, status: lastRun.status,
|
|
1352
1354
|
createdAt: lastRun.createdAt, costUsd: lastRun.costUsd,
|
|
1353
1355
|
// The card may print a dollar only where the dollar was real.
|