@amenophis1er/foreman 0.1.0 → 0.1.2

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,133 +1,221 @@
1
1
  # Foreman
2
2
 
3
- An autonomous mission runner on the [Claude Agent SDK]. You link project
4
- folders, write a mission, and Foreman's **director** agent plans it into
5
- `.foreman/MISSION.md`, delegates implementation to **worker** sessions,
6
- answers or escalates their questions, verifies the result independently, and
7
- reports backall from one browser dashboard, no terminal.
8
-
9
- Foreman is the standalone evolution of [claude-golden-eye]'s director mode:
10
- same director/worker doctrine, but the agents are embedded SDK sessions
11
- driven by this app instead of terminal sessions observed by a plugin.
12
- (Golden-eye still observes Foreman's agents for free they are real Claude
13
- Code sessions.)
3
+ Foreman runs software missions without you in the loop, and shows you
4
+ everything when you come back.
5
+
6
+ You link a project folder and say what you want. A **director** agent talks
7
+ it through with you (or not you can skip the talk), writes the plan into
8
+ `.foreman/MISSION.md`, delegates the work to **worker** sessions, watches them,
9
+ verifies the result on its own terms, and reports. You watch from one
10
+ dashboard, or from your phone, or from nowhere at all: every question the crew
11
+ could ask has an unattended default, every run has a budget it cannot cross,
12
+ and every file it touched is on record when you return.
13
+
14
+ The agents are real [Claude Agent SDK] sessions. The models behind them can be
15
+ Anthropic's, or anything that speaks OpenAI's wire: a local Ollama, Ollama
16
+ Cloud, OpenRouter, vLLM, a Codex login. Foreman never holds a credential it did
17
+ not find already on your machine.
18
+
19
+ Foreman grew out of [claude-golden-eye]'s director mode. Same doctrine —
20
+ one director, scoped workers, independent verification — but the agents are
21
+ embedded sessions driven by this app, not terminal sessions watched by a
22
+ plugin.
23
+
24
+ ## See it
25
+
26
+ <p align="center">
27
+ <img src="assets/readme/fleet-needs-you.jpg" alt="The fleet: one approval waiting, answerable in place; one mission running with its crew and meter; recent outcomes" width="900">
28
+ </p>
29
+
30
+ The planner asks with options and ends in a proposal you can edit; a run is
31
+ one screen with the checklist ticking live and the crew beside it; anything
32
+ that blocks a run sits above the transcript, answerable right there.
33
+
34
+ | Plan | Run | Review |
35
+ |---|---|---|
36
+ | [![The planner asking three questions with clickable options](assets/readme/planner-asks.jpg)](assets/readme/planner-asks.jpg) | [![A running mission: transcript, timeline, DONE WHEN and crew](assets/readme/run.jpg)](assets/readme/run.jpg) | [![The files viewer stepping through a run's fixtures](assets/readme/files-viewer.jpg)](assets/readme/files-viewer.jpg) |
37
+ | [![The proposal card: brief, DONE WHEN, budget, models, browser](assets/readme/proposal.jpg)](assets/readme/proposal.jpg) | [![An approval pinned above the transcript, waiting](assets/readme/needs-you.jpg)](assets/readme/needs-you.jpg) | [![The fleet at rest: all quiet](assets/readme/fleet-quiet.jpg)](assets/readme/fleet-quiet.jpg) |
14
38
 
15
39
  ## Install
16
40
 
17
- Foreman needs Node 20 or newer and a Claude Code login (or an API key) on the
18
- machine it runs on. Then:
41
+ Node 20 or newer, and a Claude Code login (or an API key) on the machine that
42
+ will run it.
19
43
 
20
44
  ```sh
21
- npx @amenophis1er/foreman # try it starts the server, serves http://localhost:4177
22
- npm install -g @amenophis1er/foreman # keep it then `foreman` from any shell
45
+ npx @amenophis1er/foreman # try it: starts the server, serves http://localhost:4177
46
+ npm install -g @amenophis1er/foreman # keep it: then `foreman` from any shell
23
47
  ```
24
48
 
25
49
  ```sh
26
50
  foreman doctor # what this machine can run, what is missing, how to fix it
27
51
  foreman # start in this terminal (Ctrl+C stops it)
28
- foreman up # …or in the background; foreman down stops it, foreman status asks
52
+ foreman up # …or in the background; `foreman down` stops it, `foreman status` asks
29
53
  foreman open # the dashboard, in your browser
30
54
  foreman service install # keep it running for good: start at login, restart if it dies
31
55
  foreman --help # the rest, and the environment variables
32
56
  ```
33
57
 
34
- `foreman doctor` prints the same checklist the server prints on start —
35
- credentials and which account pays, the Claude Code install, Ollama and Codex
36
- if present, the browser missions will use, the port, Tailscale, the data
37
- directory — and exits. Nothing blocks unless it says so.
58
+ `doctor` prints the checklist the server prints on start — credentials and
59
+ which account pays, the Claude Code install, Ollama and Codex if present, the
60
+ browser missions will use, the port, Tailscale, the data directory — and
61
+ exits. Nothing blocks unless it says so.
62
+
63
+ ## A mission, start to finish
64
+
65
+ 1. **Fleet.** The board: what needs you (answerable right there), what is
66
+ running with its crew and its meter, what finished. An idle fleet says
67
+ "all quiet" and means it.
68
+ 2. **Plan.** Open a project and talk to the planner. It reads the folder —
69
+ never changes it — asks with clickable options when it needs to, and ends
70
+ with a **proposal card**: the brief, the DONE WHEN checklist, a budget, the
71
+ models it suggests and why, browser on or off. Edit anything, start.
72
+ Or skip the talk and write the brief yourself.
73
+ 3. **Run.** One screen, one spine: the transcript, with anything that blocks
74
+ the run pinned above it and answerable in place. On the right, the
75
+ mission's checklist ticking live, the crew and what each worker last said,
76
+ the run's facts, and the project's settings.
77
+ 4. **Files.** What the run changed, against a baseline taken at start (git
78
+ ref or snapshot), with diffs; what it produced — screenshots, logs, work
79
+ files — viewable in place, arrow keys to step through; any dev server the
80
+ crew exposed, one click away. HTML renders in a sandbox with its own
81
+ scripts, so a built page is a page, not a source listing.
82
+ 5. **Next.** A finished run is a starting point: **Plan the next step** opens
83
+ a new planning conversation already seeded with what was built, the
84
+ mission doc, and the director's final report.
85
+
86
+ Every run persists under `~/.foreman/`: refresh-proof, replayable, resumable.
87
+ An interrupted run restores the director's session and re-verifies before it
88
+ continues. `.foreman/` git-ignores itself, so missions in real repositories
89
+ leave no trace but the work.
90
+
91
+ ## Providers, and what a dollar means
92
+
93
+ A provider is one choice, not three: **who is billed, which credential, and
94
+ which wire**. Foreman offers these, each read from what is already on the
95
+ machine:
96
+
97
+ | Provider | Credential | Wire |
98
+ |---|---|---|
99
+ | Claude Code | its own login — subscription or `ANTHROPIC_API_KEY` | Anthropic |
100
+ | Anthropic API | a key named by environment variable, never stored | Anthropic |
101
+ | Codex | the `codex login` in `~/.codex` | OpenAI-compatible, through a local gateway |
102
+ | OpenAI-compatible | whatever the endpoint wants | Ollama (local or cloud), OpenRouter, vLLM, OpenAI, … |
103
+
104
+ The director and the workers can run on different providers; a mission on
105
+ Sonnet with workers on a local model is one click in the composer, and the
106
+ planner recommends pairings with a reason.
107
+
108
+ **Cost is never invented.** A run is `priced` only when the endpoint that
109
+ sends the bill also published the rate, or when Anthropic reported the cost
110
+ itself. A local model is `free`. A cloud model with no published price is
111
+ `unpriced`, and the meter shows what is true instead: tokens in, tokens out,
112
+ turns. The one exception is a dated table of OpenAI's list prices, visible in
113
+ `src/openai-prices.ts` with the day it was checked. Budget caps bind on
114
+ dollars where dollars are real and on wall clock always.
115
+
116
+ Which account pays is printed at startup and shown wherever a mission can be
117
+ started. Pin it with `FOREMAN_CLAUDE_CONFIG_DIR`; assert it with
118
+ `FOREMAN_AUTH_MODE=api-key|subscription` so an unset key fails loudly instead
119
+ of billing the other account. A project can pin its own provider, which is
120
+ how personal and work projects share one server.
121
+
122
+ ## Unattended by design
123
+
124
+ Foreman is meant to run while you are away, so it does not stop to ask unless
125
+ a human's judgement is required *and* being wrong is expensive.
126
+
127
+ - **Three classes of "outside the folder".** Scratch output that lands in a
128
+ temp directory is denied on the spot and redirected to `.foreman/work/`.
129
+ A path that is knowably wrong is denied. A path only you can judge asks —
130
+ with a default, because "no answer" is also an answer the run must survive.
131
+ - **Every ask has a deadline and a default.** An approval nobody answers is
132
+ denied with a message that says where to go instead; a question nobody
133
+ answers is handed back to the director with "decide and record". You set
134
+ the timeouts, per project if you like.
135
+ - **Watchdogs.** A silent worker, a director looping on the same call, a run
136
+ past its wall clock: each is detected, reported in plain words, and bounded.
137
+ - **Notify → wait → default.** Link the Telegram bot and the ask reaches your
138
+ phone with buttons. The default is what happens when you truly cannot
139
+ answer, not what happens because you never knew.
140
+
141
+ ## From your phone
142
+
143
+ With the Telegram bot linked (Settings → Notifications, scan the QR):
144
+
145
+ - Approvals, questions and the planner's multiple-choice asks arrive with
146
+ inline buttons; a tap answers. Remote answers are allow, deny, pick or type
147
+ — never an "always" grant. That stays a decision for the desk.
148
+ - `/projects`, `/status`, `/new <name>`, `/plan <project> <what you want>`,
149
+ `/run <project> <brief>`, `/stop`. After `/new`, just keep typing: the
150
+ planner's replies come back to the phone, and its proposal arrives as a
151
+ card with **Start mission** and **Discard**.
152
+ - When the crew exposes a dev server, you get the link, and it opens from
153
+ anywhere your tailnet reaches.
154
+
155
+ If the machine is on [Tailscale], Foreman listens on the tailnet address too —
156
+ and never on every interface, since there is no login. The phone's links use
157
+ the tailnet name. `foreman service install` keeps the server up with the lid
158
+ closed.
159
+
160
+ ## What Foreman will not do
161
+
162
+ - **It is not a chat client.** Planning talks; missions work. The dashboard
163
+ is a control room, not a conversation.
164
+ - **It is not an editor or a file manager.** Files shows diffs and artifacts,
165
+ read-only, path-jailed to the project.
166
+ - **It mints no credentials and reimplements no OAuth.** It reads what
167
+ `claude`, `codex` and your environment already hold. No key is ever
168
+ returned by an API route or written to a project file.
169
+ - **It never guesses a price**, and it never grants standing permissions
170
+ from a phone.
171
+
172
+ ## Platforms
173
+
174
+ macOS is where Foreman is developed and tested. Linux is verified on Debian
175
+ with Node 22 — install, `doctor`, `up`/`status`/`down`, the dashboard;
176
+ `service install` needs a systemd user session, and browser missions need
177
+ Google Chrome or `npx playwright install chromium` with
178
+ `FOREMAN_BROWSER=chromium`. Windows is not yet tested natively — use WSL2;
179
+ `foreman service` has no Windows implementation, `foreman up` works.
180
+
181
+ ## Under the hood
38
182
 
39
- **On the move.** If the machine is on a [Tailscale] tailnet, Foreman listens
40
- on the tailnet address too (never on every interface there is no login),
41
- and the links it sends to your phone use the tailnet name. Link the Telegram
42
- bot in Settings Notifications and the phone can answer asks, plan and start
43
- missions, and open what the crew built. `foreman service install` is what
44
- keeps the server up while the lid is closed.
183
+ ```
184
+ bin/foreman.mjs the CLI: start · up/down/status · doctor · open · service
185
+ src/server.ts HTTP + SSE, routes, the run and planner drivers
186
+ src/orchestrator.ts MissionRun: director, workers, budget, watchdogs, asks
187
+ src/planner.ts the planning conversation: ask_user, propose_mission, forks
188
+ src/policy.ts what auto-allows, what is denied, what asks and the defaults
189
+ src/provider.ts provider + credential + wire, resolved once per role
190
+ src/gateway/ the OpenAI-compatible gateway and its token ledger
191
+ src/notify.ts the notification hub · src/notify/telegram.ts the bot
192
+ src/deck.ts baselines, diffs, artifacts · src/services.ts exposed servers
193
+ src/store.ts ~/.foreman: append-only event logs, atomic meta
194
+ ui/ React + Vite dashboard; design system under ui/src/ds
195
+ ```
196
+
197
+ One active mission per project, many across projects. Every event is an
198
+ envelope `{runId, projectId}` on one SSE stream; the persisted log is replayed
199
+ through the same reducer that renders live events, which is why a refresh
200
+ shows exactly what a watcher saw. The director's tools are in-process MCP
201
+ tools; a worker's report lands in the director's context as an ordinary tool
202
+ result.
45
203
 
46
- **Platforms.** macOS is where Foreman is developed and tested. Linux is
47
- verified on Debian with Node 22: install, `foreman doctor`, `foreman up` /
48
- `status` / `down` and the dashboard all work; `foreman service install` needs
49
- a systemd user session (a desktop, or `loginctl enable-linger`), and browser
50
- missions need Google Chrome or `npx playwright install chromium` with
51
- `FOREMAN_BROWSER=chromium`. Windows is not yet tested natively — use WSL2 for
52
- now; `foreman service` has no Windows implementation, `foreman up` works.
53
204
 
54
- **From a checkout** (contributing):
205
+ ## Contributing
55
206
 
56
207
  ```sh
57
208
  npm ci && npm run setup # dependencies, then the dashboard build
58
209
  npm start # serves http://localhost:4177
59
- npm test · npm run typecheck · npm run dev # tests · both tsconfigs · API + Vite together
210
+ npm test # 299 tests, node:test
211
+ npm run typecheck # server and dashboard
212
+ npm run dev # API + Vite together
213
+ scripts/dev-restart.sh # restarts the server only when nothing would be lost
60
214
  ```
61
215
 
62
- [Tailscale]: https://tailscale.com
63
-
64
- ### Which account pays
65
-
66
- The SDK spawns your local Claude Code engine, so missions run on whatever that
67
- install is logged in with — a Claude subscription or an `ANTHROPIC_API_KEY`.
68
- Both report real per-run cost, so budgets bind either way.
69
-
70
- A machine can hold more than one login, and `CLAUDE_CONFIG_DIR` inherited from
71
- the launching shell silently decides which one is used. So startup prints the
72
- account it resolved to, and the dashboard shows it beside every place a mission
73
- can be started:
74
-
75
- ```
76
- ✓ Credentials Claude subscription — you@example.com · Your Org
77
- ✓ Claude Code /Users/you/.claude · bundled executable
78
- ```
79
-
80
- Pin it explicitly with `FOREMAN_CLAUDE_CONFIG_DIR`, and assert the mode you
81
- intend with `FOREMAN_AUTH_MODE=api-key|subscription` so an unset key fails at
82
- startup instead of quietly billing the other account. A project can pin its own
83
- install, and choose whether it inherits the server's billing or uses that
84
- install's own login — which is what makes personal and work projects coexist on
85
- one server.
86
-
87
- Dev loop: `npm run dev` (API + Vite, proxied to :4177) · `npm test` ·
88
- `npm run typecheck` · `scripts/dev-restart.sh` (restarts the server only when
89
- no run, ask or planner turn would be lost).
90
-
91
- ## Using it
92
-
93
- 1. **Fleet** (`/`): link a folder as a project. Cards show status, live
94
- mission cost, and a pulsing **needs you** strip when approvals or
95
- questions wait.
96
- 2. **Project view**: write the mission in the composer (say what DONE looks
97
- like; flag decisions the director must ask you about), set a budget cap
98
- and optional director/worker models, Start.
99
- 3. While running: approve/deny tool cards (or **Always** per tool per run),
100
- answer director questions, watch the plan board tick as MISSION.md
101
- updates. Interrupt anytime.
102
- 4. Every run persists (`~/.foreman/`): refresh-proof, browsable history with
103
- full replay, and a **⟳ Resume** button on interrupted runs that restores
104
- the director's session and re-verifies state before continuing.
105
-
106
- Missions in real repositories are fine: `.foreman/` git-ignores itself, the
107
- repo's `CLAUDE.md` loads into workers, and git operations go through your
108
- approval cards.
109
-
110
- ## Architecture
111
-
112
- ```
113
- ui/ React 19 + Vite dashboard (design tokens in src/design/)
114
- src/server.ts HTTP + SSE wiring only
115
- src/orchestrator.ts MissionRun: director + workers + budget + escalation
116
- src/policy.ts permission policy (what auto-allows vs. asks you)
117
- src/store.ts ~/.foreman persistence: append-only event logs, atomic meta
118
- src/types.ts shared contracts
119
- ```
120
-
121
- - **One active mission per project; many across projects.** SSE frames carry
122
- `{runId, projectId}` envelopes; persisted logs are replayed through the
123
- same reducer that renders live events.
124
- - The director's tools (`spawn_worker`, `message_worker`, `ask_human`) are
125
- in-process MCP tools; worker calls block inside the director's tool call,
126
- so reports land in its context as ordinary tool results.
127
- - Budgets are enforced before any new worker work; guarded asks (e.g. writes
128
- outside the folder) always prompt, even after "Always".
129
-
130
- Design history and roadmap: [DESIGN.md](DESIGN.md).
216
+ Releases: `npm version <patch|minor|major> && git push --follow-tags` — CI
217
+ tests, publishes to npm with provenance, and creates the GitHub release.
131
218
 
132
219
  [Claude Agent SDK]: https://code.claude.com/docs/en/agent-sdk
133
- [claude-golden-eye]: ../claude-golden-eye
220
+ [claude-golden-eye]: https://github.com/amenophis1er/claude-golden-eye
221
+ [Tailscale]: https://tailscale.com
package/bin/foreman.mjs CHANGED
@@ -19,14 +19,19 @@ const USAGE = `foreman ${pkg.version}
19
19
 
20
20
  foreman Start in this terminal (same as "start"); Ctrl+C stops it
21
21
  foreman up Start in the background; logs to ~/.foreman/logs/server.log
22
- foreman down Stop a background server started with "up"
23
- foreman status Is a server up? Which port, which pid?
24
- foreman doctor Check credentials, providers, browser, port, Tailscale and exit
22
+ foreman stop Stop it, however it was started (background server, or the service)
23
+ foreman restart Stop and start it again the same way
24
+ foreman status Is a server up? Which port, how was it started?
25
+ foreman logs Tail the log
25
26
  foreman open Open the dashboard in your browser
27
+ foreman doctor Check credentials, providers, browser, port, Tailscale — and exit
28
+
26
29
  foreman service install Keep Foreman running: start at login, restart if it dies
30
+ foreman service start|stop|restart|status|logs
27
31
  foreman service uninstall Remove that
28
- foreman service status Is the service registered and running?
29
- foreman service logs Tail the service's log
32
+
33
+ foreman uninstall Remove the service and the background server; keeps ~/.foreman
34
+ foreman uninstall --purge --yes …and delete ~/.foreman (every run's history) too
30
35
  foreman --version | --help
31
36
 
32
37
  Environment:
@@ -48,7 +53,7 @@ if (command === '--help' || command === '-h' || command === 'help') {
48
53
  } else if (command === 'start') {
49
54
  register();
50
55
  await import(new URL('../src/server.ts', import.meta.url).href);
51
- } else if (['doctor', 'open', 'service', 'up', 'down', 'status'].includes(command)) {
56
+ } else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall'].includes(command)) {
52
57
  register();
53
58
  const { runCli } = await import(new URL('../src/cli.ts', import.meta.url).href);
54
59
  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.0",
3
+ "version": "0.1.2",
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",
@@ -33,14 +33,13 @@
33
33
  "ui/dist",
34
34
  "skills",
35
35
  "README.md",
36
- "DESIGN.md",
37
36
  "LICENSE"
38
37
  ],
39
38
  "engines": {
40
39
  "node": ">=20"
41
40
  },
42
41
  "scripts": {
43
- "test": "node --import tsx --test 'src/**/*.test.ts'",
42
+ "test": "node --import tsx --test src/*.test.ts src/gateway/*.test.ts src/notify/*.test.ts",
44
43
  "start": "tsx src/server.ts",
45
44
  "dev": "node scripts/dev.mjs",
46
45
  "ui:dev": "npm --prefix ui run dev",
@@ -29,8 +29,7 @@ command. Set `FOREMAN_URL` to point at a different host or port.
29
29
 
30
30
  3. **Ensure the server is up** (start detached if not):
31
31
  ```bash
32
- curl -sf -m 2 "${FOREMAN_URL:-http://localhost:4177}/projects" >/dev/null || \
33
- (nohup foreman start > /tmp/foreman-server.log 2>&1 & disown; sleep 3)
32
+ curl -sf -m 2 "${FOREMAN_URL:-http://localhost:4177}/projects" >/dev/null || foreman up
34
33
  ```
35
34
 
36
35
  4. **Link the cwd as a project** (idempotent — relinking returns the existing
package/src/ask.ts CHANGED
@@ -48,8 +48,11 @@ export type AskAnswers = Record<string, string>;
48
48
  export function armAskTimeout(ms: number, onTimeout: () => void): { cancel(): void } {
49
49
  if (!Number.isFinite(ms) || ms <= 0) return { cancel() { /* never armed */ } };
50
50
  let fired = false;
51
+ // Ref'd on purpose: a pending ask with a deadline is work the process owes,
52
+ // and it must hold the event loop until it settles. Unref'd, Node 22's
53
+ // test runner saw an empty loop under an awaited ask and failed the test;
54
+ // a server that must exit does so through its signal handlers regardless.
51
55
  const t = setTimeout(() => { fired = true; onTimeout(); }, ms);
52
- t.unref?.();
53
56
  return { cancel() { if (!fired) clearTimeout(t); } };
54
57
  }
55
58
 
package/src/cli.ts CHANGED
@@ -102,6 +102,14 @@ function serviceEnv(): Record<string, string> {
102
102
  async function serviceInstall(bin: string): Promise<number> {
103
103
  const logDir = path.join(HOME_DIR, 'logs');
104
104
  await mkdir(logDir, { recursive: true });
105
+ // A background server started with `foreman up` would keep the port and
106
+ // make the new service crash-loop against it; hand over first.
107
+ const pid = await readPid();
108
+ if (pid && alive(pid)) { console.log('Stopping the background server so the service can take the port…'); await down(); }
109
+ else if (await listening(PORT)) {
110
+ console.error(`Something else answers on :${PORT} (a terminal session?). Stop it first, or the service will keep failing to bind.`);
111
+ return 1;
112
+ }
105
113
  const env = serviceEnv();
106
114
  if (process.platform === 'darwin') {
107
115
  const dir = path.join(os.homedir(), 'Library', 'LaunchAgents');
@@ -158,6 +166,66 @@ async function serviceUninstall(): Promise<number> {
158
166
  return 2;
159
167
  }
160
168
 
169
+ /** Is the service registered, and is it running? Null pid when registered but idle. */
170
+ async function serviceState(): Promise<{ installed: boolean; running: boolean; pid?: number }> {
171
+ if (process.platform === 'darwin') {
172
+ const r = await sh('launchctl', ['print', `gui/${os.userInfo().uid}/${LABEL}`]);
173
+ if (r.code !== 0) return { installed: false, running: false };
174
+ const pid = Number(/pid = (\d+)/.exec(r.out)?.[1]);
175
+ return { installed: true, running: Number.isFinite(pid) && pid > 0, ...(pid ? { pid } : {}) };
176
+ }
177
+ if (process.platform === 'linux') {
178
+ const enabled = await sh('systemctl', ['--user', 'is-enabled', 'foreman']);
179
+ const active = await sh('systemctl', ['--user', 'is-active', 'foreman']);
180
+ return { installed: enabled.out.trim() === 'enabled' || active.out.trim() === 'active', running: active.out.trim() === 'active' };
181
+ }
182
+ return { installed: false, running: false };
183
+ }
184
+
185
+ /** Stop the service without removing it; `service start` brings it back. */
186
+ async function serviceStop(): Promise<number> {
187
+ const st = await serviceState();
188
+ if (!st.installed) { console.log('The service is not installed.'); return 1; }
189
+ if (process.platform === 'darwin') {
190
+ // bootout unloads the job until the next login or `service start`; a plain
191
+ // kill would be undone by KeepAlive within seconds.
192
+ const r = await sh('launchctl', ['bootout', `gui/${os.userInfo().uid}/${LABEL}`]);
193
+ if (r.code !== 0 && st.running) { console.error(`launchctl bootout failed: ${r.err.trim() || r.out.trim()}`); return 1; }
194
+ } else if (process.platform === 'linux') {
195
+ const r = await sh('systemctl', ['--user', 'stop', 'foreman']);
196
+ if (r.code !== 0) { console.error(`systemctl stop failed: ${r.err.trim() || r.out.trim()}`); return 1; }
197
+ }
198
+ console.log('Service stopped. `foreman service start` starts it again; it also comes back at next login.');
199
+ return 0;
200
+ }
201
+
202
+ async function serviceStart(): Promise<number> {
203
+ if (process.platform === 'darwin') {
204
+ const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
205
+ const r = await sh('launchctl', ['bootstrap', `gui/${os.userInfo().uid}`, file]);
206
+ if (r.code !== 0 && !/already/i.test(r.err)) { console.error(`launchctl bootstrap failed: ${r.err.trim() || r.out.trim() || 'is the service installed?'}`); return 1; }
207
+ } else if (process.platform === 'linux') {
208
+ const r = await sh('systemctl', ['--user', 'start', 'foreman']);
209
+ if (r.code !== 0) { console.error(`systemctl start failed: ${r.err.trim() || r.out.trim()}`); return 1; }
210
+ } else { return 2; }
211
+ for (let i = 0; i < 40; i++) { if (await listening(PORT)) break; await new Promise((r) => setTimeout(r, 250)); }
212
+ console.log((await listening(PORT)) ? `Service started · http://localhost:${PORT}` : `Service started; not answering yet — see ${LOG_FILE}`);
213
+ return 0;
214
+ }
215
+
216
+ async function serviceRestart(): Promise<number> {
217
+ if (process.platform === 'darwin') {
218
+ const r = await sh('launchctl', ['kickstart', '-k', `gui/${os.userInfo().uid}/${LABEL}`]);
219
+ if (r.code !== 0) { console.error(`launchctl kickstart failed: ${r.err.trim() || r.out.trim() || 'is the service installed?'}`); return 1; }
220
+ } else if (process.platform === 'linux') {
221
+ const r = await sh('systemctl', ['--user', 'restart', 'foreman']);
222
+ if (r.code !== 0) { console.error(`systemctl restart failed: ${r.err.trim() || r.out.trim()}`); return 1; }
223
+ } else { return 2; }
224
+ for (let i = 0; i < 40; i++) { if (await listening(PORT)) break; await new Promise((r) => setTimeout(r, 250)); }
225
+ console.log((await listening(PORT)) ? `Service restarted · http://localhost:${PORT}` : `Service restarted; not answering yet — see ${LOG_FILE}`);
226
+ return 0;
227
+ }
228
+
161
229
  async function serviceStatus(): Promise<number> {
162
230
  if (process.platform === 'darwin') {
163
231
  const r = await sh('launchctl', ['print', `gui/${os.userInfo().uid}/${LABEL}`]);
@@ -223,6 +291,14 @@ function alive(pid: number): boolean {
223
291
 
224
292
  async function up(bin: string): Promise<number> {
225
293
  if (await listening(PORT)) { console.log(`Already up at http://localhost:${PORT}`); return 0; }
294
+ // One owner per port. With the service installed, a second server started
295
+ // here would hold the port and leave launchd/systemd retrying its own copy
296
+ // every few seconds against the preflight's "already in use".
297
+ const svc = await serviceState();
298
+ if (svc.installed) {
299
+ console.log('The service runs Foreman on this machine. Use `foreman service start` (or `restart`); `foreman service uninstall` if you would rather run it by hand.');
300
+ return 1;
301
+ }
226
302
  await mkdir(path.dirname(LOG_FILE), { recursive: true });
227
303
  const out = openSync(LOG_FILE, 'a');
228
304
  const child = spawn(process.execPath, [bin, 'start'], {
@@ -263,7 +339,8 @@ async function status(): Promise<number> {
263
339
  const pid = await readPid();
264
340
  const isUp = await listening(PORT);
265
341
  if (isUp) {
266
- const how = pid && alive(pid) ? `background, pid ${pid}` : 'a terminal or the service';
342
+ const svc = await serviceState();
343
+ const how = pid && alive(pid) ? `background, pid ${pid}` : svc.running ? `the service${svc.pid ? `, pid ${svc.pid}` : ''}` : 'a terminal';
267
344
  console.log(`Up at http://localhost:${PORT} (${how})`);
268
345
  return 0;
269
346
  }
@@ -272,11 +349,80 @@ async function status(): Promise<number> {
272
349
  return 1;
273
350
  }
274
351
 
352
+ /**
353
+ * Stop whatever is serving :PORT, by the means it was started with. A
354
+ * background server (pid file) is signalled; a service is stopped through
355
+ * its manager, since killing it would only make KeepAlive restart it; a
356
+ * terminal session is yours to Ctrl+C, and it says so.
357
+ */
358
+ async function stop(): Promise<number> {
359
+ const pid = await readPid();
360
+ if (pid && alive(pid)) return down();
361
+ const svc = await serviceState();
362
+ if (svc.running) return serviceStop();
363
+ if (await listening(PORT)) {
364
+ console.log(`Something answers on :${PORT} that neither \`foreman up\` nor the service started — a terminal session, most likely. Stop it there (Ctrl+C).`);
365
+ return 1;
366
+ }
367
+ await rm(PID_FILE, { force: true });
368
+ console.log('Not up.');
369
+ return 0;
370
+ }
371
+
372
+ async function restart(bin: string): Promise<number> {
373
+ const pid = await readPid();
374
+ if (pid && alive(pid)) { const c = await down(); if (c !== 0) return c; return up(bin); }
375
+ const svc = await serviceState();
376
+ if (svc.running) return serviceRestart();
377
+ if (await listening(PORT)) {
378
+ console.log(`The server on :${PORT} was started in a terminal; restart it there. (\`foreman up\` and \`foreman service install\` are the restartable ways to run it.)`);
379
+ return 1;
380
+ }
381
+ return up(bin);
382
+ }
383
+
384
+ async function logs(): Promise<number> {
385
+ const svc = await serviceState();
386
+ if (process.platform === 'linux' && svc.installed) return serviceLogs();
387
+ const child = spawn('tail', ['-n', '100', '-f', LOG_FILE], { stdio: 'inherit' });
388
+ return new Promise((r) => child.on('exit', (c) => r(c ?? 0)));
389
+ }
390
+
391
+ /**
392
+ * Take Foreman off this machine, in the right order: the service, then a
393
+ * background server, then — only with --purge, which destroys every run's
394
+ * history — the data directory. The package itself is npm's to remove; the
395
+ * last line says how, because a bin cannot uninstall the package it lives in.
396
+ */
397
+ async function uninstall(flags: string[]): Promise<number> {
398
+ const purge = flags.includes('--purge');
399
+ const svc = await serviceState();
400
+ if (svc.installed) await serviceUninstall();
401
+ const pid = await readPid();
402
+ if (pid && alive(pid)) await down();
403
+ if (purge) {
404
+ if (!flags.includes('--yes')) {
405
+ console.error(`--purge deletes ${HOME_DIR}: every run, its transcripts and settings, the Telegram link. Add --yes to confirm.`);
406
+ return 2;
407
+ }
408
+ await rm(HOME_DIR, { recursive: true, force: true });
409
+ console.log(`Removed ${HOME_DIR}.`);
410
+ } else {
411
+ console.log(`Kept ${HOME_DIR} (runs, settings). \`foreman uninstall --purge --yes\` removes it too.`);
412
+ }
413
+ console.log('Now remove the package:\n npm uninstall -g @amenophis1er/foreman');
414
+ return 0;
415
+ }
416
+
275
417
  export async function runCli(command: string, rest: string[], ctx: { version: string; bin: URL }): Promise<number> {
276
418
  const bin = fileURLToPath(ctx.bin);
277
419
  switch (command) {
278
420
  case 'up': return up(bin);
279
421
  case 'down': return down();
422
+ case 'stop': return stop();
423
+ case 'restart': return restart(bin);
424
+ case 'logs': return logs();
425
+ case 'uninstall': return uninstall(rest);
280
426
  case 'status': return status();
281
427
  case 'doctor': return doctor();
282
428
  case 'open': return open();
@@ -284,9 +430,12 @@ export async function runCli(command: string, rest: string[], ctx: { version: st
284
430
  const sub = rest[0];
285
431
  if (sub === 'install') return serviceInstall(bin);
286
432
  if (sub === 'uninstall') return serviceUninstall();
433
+ if (sub === 'start') return serviceStart();
434
+ if (sub === 'stop') return serviceStop();
435
+ if (sub === 'restart') return serviceRestart();
287
436
  if (sub === 'status') return serviceStatus();
288
437
  if (sub === 'logs') return serviceLogs();
289
- console.error('foreman service <install|uninstall|status|logs>');
438
+ console.error('foreman service <install|uninstall|start|stop|restart|status|logs>');
290
439
  return 1;
291
440
  }
292
441
  default: return 1;
package/src/deck.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Deck — what a mission changed, and what it produced.
3
3
  *
4
- * DESIGN.md §11 fixes the boundary: "The deck is diff and artifacts, not a
4
+ * The product's non-goals fix the boundary: "The deck is diff and artifacts, not a
5
5
  * file manager and not an editor. Its job is to show what this mission
6
6
  * changed, which is something your editor cannot tell you and Foreman can."
7
7
  * Everything here is read-only with respect to the user's project. The only