@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 +193 -105
- package/bin/foreman.mjs +11 -6
- package/package.json +3 -4
- package/skills/director/SKILL.md +1 -2
- package/src/ask.ts +4 -1
- package/src/cli.ts +151 -2
- package/src/deck.ts +1 -1
- package/src/server.ts +35 -9
- package/ui/dist/assets/{index-lcBy-uRZ.js → index-D56hYFVC.js} +29 -28
- package/ui/dist/index.html +1 -1
- package/DESIGN.md +0 -408
package/README.md
CHANGED
|
@@ -1,133 +1,221 @@
|
|
|
1
1
|
# Foreman
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
| [](assets/readme/planner-asks.jpg) | [](assets/readme/run.jpg) | [](assets/readme/files-viewer.jpg) |
|
|
37
|
+
| [](assets/readme/proposal.jpg) | [](assets/readme/needs-you.jpg) | [](assets/readme/fleet-quiet.jpg) |
|
|
14
38
|
|
|
15
39
|
## Install
|
|
16
40
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
22
|
-
npm install -g @amenophis1er/foreman # keep it
|
|
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
|
-
`
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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]:
|
|
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
|
|
23
|
-
foreman
|
|
24
|
-
foreman
|
|
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
|
-
|
|
29
|
-
foreman service
|
|
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.
|
|
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": "
|
|
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
|
|
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",
|
package/skills/director/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|