@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 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",
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: false, running: false };
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) { 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
+ }
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, so changing models or tool policy after a
1154
- // failure takes effect on the retry. A director session cannot switch
1155
- // model mid-session, so a changed director model restarts the session
1156
- // fresh (the mission doc carries the state forward).
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.
1169
- const directorChanged =
1170
- (settings.directorModel !== undefined && settings.directorModel !== meta.directorModel)
1171
- || (settings.directorModel !== undefined && settings.directorProviderId !== meta.directorProviderId);
1172
- const workerChanged =
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.