gsd-unsupervised 1.0.0
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 +263 -0
- package/bin/gsd-unsupervised +3 -0
- package/bin/start-daemon.sh +12 -0
- package/bin/unsupervised-gsd +2 -0
- package/dist/agent-runner.d.ts +26 -0
- package/dist/agent-runner.js +111 -0
- package/dist/agent-runner.spawn.test.d.ts +1 -0
- package/dist/agent-runner.spawn.test.js +128 -0
- package/dist/agent-runner.test.d.ts +1 -0
- package/dist/agent-runner.test.js +26 -0
- package/dist/bootstrap/wsl-bootstrap.d.ts +11 -0
- package/dist/bootstrap/wsl-bootstrap.js +14 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +172 -0
- package/dist/config/paths.d.ts +8 -0
- package/dist/config/paths.js +36 -0
- package/dist/config/wsl.d.ts +4 -0
- package/dist/config/wsl.js +43 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.js +95 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +27 -0
- package/dist/cursor-agent.d.ts +17 -0
- package/dist/cursor-agent.invoker.test.d.ts +1 -0
- package/dist/cursor-agent.invoker.test.js +150 -0
- package/dist/cursor-agent.js +156 -0
- package/dist/cursor-agent.test.d.ts +1 -0
- package/dist/cursor-agent.test.js +60 -0
- package/dist/daemon.d.ts +17 -0
- package/dist/daemon.js +374 -0
- package/dist/git.d.ts +23 -0
- package/dist/git.js +76 -0
- package/dist/goals.d.ts +34 -0
- package/dist/goals.js +148 -0
- package/dist/gsd-state.d.ts +49 -0
- package/dist/gsd-state.js +76 -0
- package/dist/init-wizard.d.ts +5 -0
- package/dist/init-wizard.js +96 -0
- package/dist/lifecycle.d.ts +41 -0
- package/dist/lifecycle.js +103 -0
- package/dist/lifecycle.test.d.ts +1 -0
- package/dist/lifecycle.test.js +116 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +31 -0
- package/dist/notifier.d.ts +6 -0
- package/dist/notifier.js +37 -0
- package/dist/orchestrator.d.ts +35 -0
- package/dist/orchestrator.js +791 -0
- package/dist/resource-governor.d.ts +54 -0
- package/dist/resource-governor.js +57 -0
- package/dist/resource-governor.test.d.ts +1 -0
- package/dist/resource-governor.test.js +33 -0
- package/dist/resume-pointer.d.ts +36 -0
- package/dist/resume-pointer.js +116 -0
- package/dist/roadmap-parser.d.ts +24 -0
- package/dist/roadmap-parser.js +105 -0
- package/dist/roadmap-parser.test.d.ts +1 -0
- package/dist/roadmap-parser.test.js +57 -0
- package/dist/session-log.d.ts +53 -0
- package/dist/session-log.js +92 -0
- package/dist/session-log.test.d.ts +1 -0
- package/dist/session-log.test.js +146 -0
- package/dist/state-index.d.ts +5 -0
- package/dist/state-index.js +31 -0
- package/dist/state-parser.d.ts +13 -0
- package/dist/state-parser.js +82 -0
- package/dist/state-parser.test.d.ts +1 -0
- package/dist/state-parser.test.js +228 -0
- package/dist/state-types.d.ts +20 -0
- package/dist/state-types.js +1 -0
- package/dist/state-watcher.d.ts +49 -0
- package/dist/state-watcher.js +148 -0
- package/dist/status-server.d.ts +112 -0
- package/dist/status-server.js +379 -0
- package/dist/status-server.test.d.ts +1 -0
- package/dist/status-server.test.js +206 -0
- package/dist/stream-events.d.ts +423 -0
- package/dist/stream-events.js +87 -0
- package/dist/stream-events.test.d.ts +1 -0
- package/dist/stream-events.test.js +304 -0
- package/dist/todos-api.d.ts +5 -0
- package/dist/todos-api.js +35 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
### gsd-unsupervised
|
|
2
|
+
|
|
3
|
+
Autonomous orchestrator that drives Cursor's headless agent through the full [GSD (Get Shit Done)](https://github.com/get-shit-done) lifecycle. It reads goals from a queue, invokes `cursor-agent` with GSD commands, monitors progress via `.planning/STATE.md`, and advances phases automatically. Built for reliable, hands-off goal-to-completion automation on a single machine.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Goal queue** — Define work in `goals.md`; the daemon processes pending goals sequentially or in parallel.
|
|
8
|
+
- **GSD lifecycle** — Runs `/gsd/new-project` → `/gsd/create-roadmap` → `/gsd/plan-phase` → `/gsd/execute-plan` in the correct order.
|
|
9
|
+
- **Cursor agent integration** — Spawns `cursor-agent` headlessly, streams commands, and handles process lifecycle (timeouts, tree-kill on shutdown).
|
|
10
|
+
- **State monitoring** — Watches `.planning/STATE.md` for phase/plan progress and emits events (phase_advanced, plan_advanced, phase_completed, goal_completed).
|
|
11
|
+
- **Crash detection & recovery** — Session log at project root, resume from exact phase/plan on next run, heartbeat for liveness.
|
|
12
|
+
- **Resource governor** — CPU + memory headroom checks before each agent call so the daemon backs off instead of thrashing your box.
|
|
13
|
+
- **Local status dashboard** — Optional HTTP server (`--status-server <port>`) serving an HTML dashboard and `/api/status` JSON. Use `--ngrok` to have the daemon run `ngrok http <port>` so the dashboard is reachable via a public URL while the process runs.
|
|
14
|
+
- **Optional SMS (Twilio)** — Notifications for goal complete, goal failed, and daemon paused; requires `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM`, `TWILIO_TO`. If unset, the daemon runs without SMS.
|
|
15
|
+
|
|
16
|
+
## Prerequisites
|
|
17
|
+
|
|
18
|
+
- **Node.js** ≥ 18
|
|
19
|
+
- **Cursor** with GSD rules installed (e.g. in `.cursor/rules/`)
|
|
20
|
+
- **cursor-agent** CLI (path configurable; default `agent`)
|
|
21
|
+
- **CURSOR_API_KEY** — Required for live runs. Get from Cursor Dashboard → Cloud Agents → User API Keys. Not required for `--dry-run`.
|
|
22
|
+
|
|
23
|
+
### WSL Support & Paths
|
|
24
|
+
|
|
25
|
+
This project is WSL-aware and includes helpers for path resolution when running under WSL2:
|
|
26
|
+
|
|
27
|
+
- **WSL detection** lives in `src/config/wsl.ts`, which can answer whether the current process is running under WSL and convert `/mnt/<drive>/...` paths to Windows-style `X:\...` paths.
|
|
28
|
+
- **Centralized path resolution** is provided by `src/config/paths.ts`:
|
|
29
|
+
- `getCursorBinaryPath` chooses the effective Cursor agent binary path, preferring the `GSD_CURSOR_BIN` environment variable, then `cursorAgentPath` from config, and finally falling back to `cursor-agent`. On WSL it can map `/mnt/*` paths to Windows-style paths when needed.
|
|
30
|
+
- `getClipExePath` resolves a Windows `clip.exe` location when running under WSL (defaulting to `C:\Windows\System32\clip.exe`), or returns `null` when clipboard integration is unavailable.
|
|
31
|
+
- `getWorkspaceDisplayPath` exposes both the WSL path and, when possible, a corresponding Windows path for the workspace root.
|
|
32
|
+
- **WSL bootstrap** in `src/bootstrap/wsl-bootstrap.ts` wires these helpers together and is invoked from the CLI startup so the daemon has a single place to understand the current environment.
|
|
33
|
+
|
|
34
|
+
When `clip.exe` cannot be resolved (for example, on non-WSL Linux), clipboard integration should be treated as optional by higher-level tooling: consumers should check for `null` and simply skip clipboard-related features instead of failing daemon startup.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
From npm (recommended):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install -g gsd-unsupervised
|
|
42
|
+
# or
|
|
43
|
+
npx gsd-unsupervised init
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
From source:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
git clone <repo-url>
|
|
50
|
+
cd gsd-unsupervised
|
|
51
|
+
npm install
|
|
52
|
+
npm run build
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### WSL Bootstrap (one command)
|
|
56
|
+
|
|
57
|
+
On WSL2, from the project root:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
bash setup.sh # Detect WSL2, sync GSD rules from Windows .cursor into repo
|
|
61
|
+
bash setup.sh --dry-run # Show what would be done (no changes)
|
|
62
|
+
bash setup.sh --validate # Bootstrap + validation checks + orchestrator smoke test
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Prerequisites:** WSL2, Cursor installed on Windows with GSD rules in `.cursor/rules`, and (for `--validate`) Node.js ≥18 and npm. A successful run creates or updates `.cursor/rules` in the repo and (with `--validate`) runs the test suite. Re-runs are idempotent.
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
### Two modes
|
|
70
|
+
|
|
71
|
+
- **SELF** — Daemon improves this repo (`gsd-unsupervised`). Workspace and goals live here; state in `.gsd/state.json`.
|
|
72
|
+
- **PROJECT** — Daemon works on another repo. You run `npx gsd-unsupervised init` in that repo; state and goals live under that repo’s `.gsd/`.
|
|
73
|
+
|
|
74
|
+
### First-time setup (any repo)
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npx gsd-unsupervised init
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Prompts: project name, repo path, first goal, Twilio SMS (y/n), public dashboard via ngrok (y/n). Writes `.gsd/state.json`, goals, and optional `.env`. Then start with `./run`.
|
|
81
|
+
|
|
82
|
+
### Recommended (dashboard + public URL)
|
|
83
|
+
|
|
84
|
+
From the project root you can use the **`run`** script (reads `.gsd/state.json`, loads `.env`, starts daemon + optional ngrok + tmux):
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
./run
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
If there is no `.gsd/state.json`, run `npx gsd-unsupervised init` first.
|
|
91
|
+
|
|
92
|
+
Or run the daemon explicitly with the status server and ngrok:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
export CURSOR_API_KEY=your_key_here
|
|
96
|
+
./bin/gsd-unsupervised --goals goals.md --status-server 4173 --ngrok --verbose
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Extra args are passed through (e.g. `./run --parallel`).
|
|
100
|
+
|
|
101
|
+
- **Status server** on port `4173`: open `http://localhost:4173` for the HTML dashboard.
|
|
102
|
+
- **ngrok** runs `ngrok http 4173` for the same process; the public URL appears in the terminal. When the daemon exits, ngrok is stopped.
|
|
103
|
+
|
|
104
|
+
Requires [ngrok](https://ngrok.com/) on your PATH and an ngrok authtoken (e.g. `ngrok config add-authtoken <token>`). Set `CURSOR_API_KEY` in your environment or in a `.env` file.
|
|
105
|
+
|
|
106
|
+
### Other ways to run
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Preview the goal queue (no API key needed)
|
|
110
|
+
./bin/gsd-unsupervised --dry-run --goals goals.md
|
|
111
|
+
|
|
112
|
+
# Run without dashboard
|
|
113
|
+
./bin/gsd-unsupervised --goals goals.md --verbose
|
|
114
|
+
|
|
115
|
+
# Dashboard only (no ngrok, localhost only)
|
|
116
|
+
./bin/gsd-unsupervised --goals goals.md --status-server 4173 --verbose
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### CLI options
|
|
120
|
+
|
|
121
|
+
| Option | Default | Description |
|
|
122
|
+
|--------|---------|-------------|
|
|
123
|
+
| `--goals <path>` | `./goals.md` | Path to the goals queue file |
|
|
124
|
+
| `--config <path>` | `./.autopilot/config.json` | Config file (optional) |
|
|
125
|
+
| `--parallel` | `false` | Enable parallel project execution |
|
|
126
|
+
| `--max-concurrent <n>` | `3` | Max concurrent goals when `--parallel` |
|
|
127
|
+
| `--verbose` | `false` | Debug logging and pretty output |
|
|
128
|
+
| `--dry-run` | `false` | Parse goals and show plan only; no agent calls |
|
|
129
|
+
| `--agent <name>` | `cursor` | Agent type: `cursor`, `claude-code`, `gemini-cli`, `codex`. Invalid names fail fast. |
|
|
130
|
+
| `--agent-path <path>` | `agent` | Path to cursor-agent binary |
|
|
131
|
+
| `--agent-timeout <ms>` | `600000` | Agent invocation timeout (ms) |
|
|
132
|
+
| `--status-server <port>` | — | Enable local HTTP status server: GET / = dashboard HTML, GET /status or /api/status = JSON |
|
|
133
|
+
| `--ngrok` | `false` | Start `ngrok http <port>` when status server is enabled; tunnel and process share the same lifecycle |
|
|
134
|
+
|
|
135
|
+
### Agent selection (`--agent`)
|
|
136
|
+
|
|
137
|
+
The `--agent` flag selects which AI coding agent the orchestrator invokes. Supported values: `cursor` (default), `claude-code`, `gemini-cli`, `codex`. Invalid names fail fast at startup and do not start the daemon. Omitting the flag or using `--agent=cursor` yields identical behavior to the original Cursor-only implementation (backward compatible). Non-Cursor agents are currently stub placeholders (TODO).
|
|
138
|
+
|
|
139
|
+
### Goals file (`goals.md`)
|
|
140
|
+
|
|
141
|
+
Use sections **Pending**, **In Progress**, and **Done**. List goals as markdown checkboxes under the right section. The orchestrator processes items in **Pending** and moves them to **In Progress** / **Done** as it runs.
|
|
142
|
+
|
|
143
|
+
Example:
|
|
144
|
+
|
|
145
|
+
```markdown
|
|
146
|
+
## Pending
|
|
147
|
+
- [ ] Your next goal
|
|
148
|
+
|
|
149
|
+
## In Progress
|
|
150
|
+
<!-- moved here while running -->
|
|
151
|
+
|
|
152
|
+
## Done
|
|
153
|
+
<!-- completed goals -->
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
All roadmap phases (1–7) are implemented: Foundation, Lifecycle, Agent Integration, State Monitoring, Crash Detection & Recovery, Status Server, WSL Bootstrap. Use `goals.md` for new work items.
|
|
157
|
+
|
|
158
|
+
## Configuration
|
|
159
|
+
|
|
160
|
+
Config can come from a JSON file (`--config`) and is overridden by CLI options. All fields are optional.
|
|
161
|
+
|
|
162
|
+
| Field | Default | Description |
|
|
163
|
+
|-------|---------|-------------|
|
|
164
|
+
| `goalsPath` | `"./goals.md"` | Goals file path |
|
|
165
|
+
| `parallel` | `false` | Parallel mode |
|
|
166
|
+
| `maxConcurrent` | `3` | Max concurrent goals (1–10) |
|
|
167
|
+
| `verbose` | `false` | Verbose logging |
|
|
168
|
+
| `logLevel` | `"info"` | `debug` \| `info` \| `warn` \| `error` |
|
|
169
|
+
| `workspaceRoot` | `process.cwd()` | Project root (for `.planning/`, etc.) |
|
|
170
|
+
| `agent` | `"cursor"` | Agent type: `cursor`, `claude-code`, `gemini-cli`, `codex` |
|
|
171
|
+
| `cursorAgentPath` | `"cursor-agent"` | cursor-agent binary path |
|
|
172
|
+
| `agentTimeoutMs` | `600000` | Agent timeout (≥ 10000) |
|
|
173
|
+
| `sessionLogPath` | `"./session-log.jsonl"` | Session log file |
|
|
174
|
+
| `stateWatchDebounceMs` | `500` | STATE.md watcher debounce (≥ 100) |
|
|
175
|
+
| `requireCleanGitBeforePlan` | `true` | Refuse execute-plan when git working tree is dirty |
|
|
176
|
+
| `autoCheckpoint` | `false` | When true and tree dirty, create a checkpoint commit before plan |
|
|
177
|
+
| `statusServerPort` | — | When set, start local HTTP status server on this port (dashboard + /api/status) |
|
|
178
|
+
| `ngrok` | `false` | When true and status server is enabled, run `ngrok http <port>` for the process lifetime |
|
|
179
|
+
|
|
180
|
+
Example `.autopilot/config.json`:
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{
|
|
184
|
+
"goalsPath": "./goals.md",
|
|
185
|
+
"verbose": true,
|
|
186
|
+
"stateWatchDebounceMs": 500
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Project structure
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
├── bin/gsd-unsupervised # CLI entry (Node)
|
|
194
|
+
├── src/
|
|
195
|
+
│ ├── cli.ts # Commander setup, dry-run, daemon entry
|
|
196
|
+
│ ├── config.ts # Zod config schema and loader
|
|
197
|
+
│ ├── daemon.ts # Goal loop, StateWatcher per goal
|
|
198
|
+
│ ├── orchestrator.ts # GSD state machine, agent invoker, reportProgress
|
|
199
|
+
│ ├── lifecycle.ts # Goal phases and command sequence
|
|
200
|
+
│ ├── goals.ts # goals.md parser
|
|
201
|
+
│ ├── roadmap-parser.ts # ROADMAP.md / phase / plan discovery
|
|
202
|
+
│ ├── state-parser.ts # STATE.md "Current Position" parser
|
|
203
|
+
│ ├── state-watcher.ts # Chokidar watcher, progress events
|
|
204
|
+
│ ├── cursor-agent.ts # cursor-agent invoker, API key validation
|
|
205
|
+
│ ├── logger.ts # Pino logger init
|
|
206
|
+
│ └── ...
|
|
207
|
+
├── .planning/ # GSD project state (STATE.md, ROADMAP.md, phases/)
|
|
208
|
+
├── goals.md # Goal queue
|
|
209
|
+
└── package.json
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
See `docs/ARCHITECTURE.md` for module roles and data flow.
|
|
213
|
+
|
|
214
|
+
## Crash detection and recovery
|
|
215
|
+
|
|
216
|
+
The daemon appends one JSON line per agent run to **session-log.jsonl** at the project root (config `sessionLogPath`, default `./session-log.jsonl`). Each entry includes `goalTitle`, `phaseNumber`, `planNumber`, and `status` (`running` | `done` | `crashed` | `timeout`). On startup, if the last entry is `running` or `crashed` and the first pending goal matches, the daemon computes a resume point from STATE.md (or the log) and passes it to the orchestrator, which re-runs only that plan then continues.
|
|
217
|
+
|
|
218
|
+
**Example session-log.jsonl (2 lines):**
|
|
219
|
+
|
|
220
|
+
```jsonl
|
|
221
|
+
{"timestamp":"2026-03-17T12:00:00.000Z","goalTitle":"Complete Phase 5","phase":"/gsd/execute-plan","phaseNumber":2,"planNumber":1,"sessionId":null,"command":"/gsd/execute-plan .planning/phases/02-x/02-01-PLAN.md","status":"running"}
|
|
222
|
+
{"timestamp":"2026-03-17T12:05:00.000Z","goalTitle":"Complete Phase 5","phase":"/gsd/execute-plan","phaseNumber":2,"planNumber":1,"sessionId":"abc","command":"/gsd/execute-plan .planning/phases/02-x/02-01-PLAN.md","status":"crashed","durationMs":300000,"error":"Agent exited with code 1"}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Corresponding STATE.md Current Position (when crash occurred):**
|
|
226
|
+
|
|
227
|
+
```markdown
|
|
228
|
+
## Current Position
|
|
229
|
+
Phase: 2 of 7 (Core Orchestration Loop)
|
|
230
|
+
Plan: 1 of 3 in current phase
|
|
231
|
+
Status: Executing plan
|
|
232
|
+
Last activity: 2026-03-17 — Running 02-01-PLAN.md
|
|
233
|
+
Progress: ██░░░░░░░░ 14%
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Resume uses this to re-run `execute-plan` for phase 2 plan 1 only, then continue.
|
|
237
|
+
|
|
238
|
+
- **requireCleanGitBeforePlan** (default `true`): the orchestrator refuses to run `execute-plan` when the git working tree has uncommitted changes, unless **autoCheckpoint** is `true`, in which case it creates a checkpoint commit first.
|
|
239
|
+
- **How to recover manually:** (1) Inspect `session-log.jsonl` (last line = last run; `status` `crashed` or `running`). (2) Read `.planning/STATE.md` for "Current Position" (phase/plan). (3) Either run the daemon again with the same goal so it resumes automatically, or run `/gsd/execute-plan .planning/phases/<phase-dir>/<phase>-<plan>-PLAN.md` for the failed plan.
|
|
240
|
+
|
|
241
|
+
**Status server and dashboard:** Use `--status-server <port>` to enable the local HTTP status server (e.g. `./bin/gsd-unsupervised --goals goals.md --status-server 4173`). Add `--ngrok` to have the daemon run `ngrok http <port>` for the same lifecycle: the public URL appears in ngrok’s output and the tunnel is closed when the daemon exits. `GET /` serves the HTML dashboard; `GET /status` returns legacy JSON; `GET /api/status` returns rich JSON including `stateSnapshot`, session log window, git feed, and `systemLoad`. `GET /api/config` and `POST /api/config` expose and update `.planning/config.json` (used for the sequential/parallel toggle).
|
|
242
|
+
|
|
243
|
+
**Hot-reload and webhook:** The daemon watches `goals.md` and merges new pending goals into the queue when the file changes. With the status server running: **POST /api/goals** (JSON `{ "title": "...", "priority": 1 }`) appends to goals and enqueues; **POST /api/todos** (JSON `{ "title": "...", "area": "api" }`) creates `.planning/todos/pending/`; **POST /webhook/twilio** accepts inbound SMS (e.g. `add <goal>` or `todo <task>`) and replies with TwiML. Point your Twilio number webhook at `<ngrok-url>/webhook/twilio`.
|
|
244
|
+
|
|
245
|
+
**Parallel goal pool:** With `--parallel`, a worker pool of size `--max-concurrent` is used; a per-workspace mutex keeps one goal running at a time for a single workspace (phase-level parallel inside execute-phase still applies).
|
|
246
|
+
|
|
247
|
+
**SMS (Twilio):** Optional. Set `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM`, and `TWILIO_TO` to receive SMS on goal complete, goal failed, and daemon paused (after 3 retries). If any are unset, SMS is skipped and the daemon runs normally.
|
|
248
|
+
|
|
249
|
+
**State and heartbeat:** When started via `./run` or `gsd-unsupervised run --state .gsd/state.json`, the daemon writes to `.gsd/state.json` (PID, current goal, progress, `lastHeartbeat`). You can use `lastHeartbeat` in an external cron or script to send a periodic "alive" SMS (e.g. every 30 min) or alert if the heartbeat is stale (e.g. >10 min).
|
|
250
|
+
|
|
251
|
+
## Development
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
npm run build # Compile TypeScript
|
|
255
|
+
npm test # Run tests (Vitest)
|
|
256
|
+
npm run dev # Watch build
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Tests include state parser, stream events, lifecycle, session-log, roadmap-parser, status-server, and resume integration. Run with `npm test` or `npm test -- state-parser`. Integration tests (crash/resume): `npm run test:integration`.
|
|
260
|
+
|
|
261
|
+
## License
|
|
262
|
+
|
|
263
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
+
cd "$ROOT"
|
|
6
|
+
|
|
7
|
+
# Thin wrapper around the CLI entrypoint so the daemon can be started via:
|
|
8
|
+
# bin/start-daemon.sh --goals ./goals.md --config ./.autopilot/config.json
|
|
9
|
+
#
|
|
10
|
+
# All arguments are forwarded to the underlying CLI.
|
|
11
|
+
node dist/cli.js "$@"
|
|
12
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { type CursorStreamEvent, type ResultEvent } from './stream-events.js';
|
|
3
|
+
/** Supported agent IDs for the pluggable invoker seam. */
|
|
4
|
+
export type AgentId = 'cursor' | 'claude-code' | 'gemini-cli' | 'codex';
|
|
5
|
+
export declare const SUPPORTED_AGENTS: readonly AgentId[];
|
|
6
|
+
export declare function isSupportedAgent(id: string): id is AgentId;
|
|
7
|
+
export interface RunAgentOptions {
|
|
8
|
+
agentPath: string;
|
|
9
|
+
workspace: string;
|
|
10
|
+
prompt: string;
|
|
11
|
+
env?: Record<string, string>;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
model?: string;
|
|
14
|
+
resumeId?: string;
|
|
15
|
+
onEvent?: (event: CursorStreamEvent) => void;
|
|
16
|
+
}
|
|
17
|
+
export interface RunAgentResult {
|
|
18
|
+
sessionId: string | null;
|
|
19
|
+
resultEvent: ResultEvent | null;
|
|
20
|
+
events: CursorStreamEvent[];
|
|
21
|
+
exitCode: number | null;
|
|
22
|
+
timedOut: boolean;
|
|
23
|
+
stderr: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function runAgent(options: RunAgentOptions): Promise<RunAgentResult>;
|
|
26
|
+
export declare function abortAgent(child: ChildProcess): Promise<void>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import treeKill from 'tree-kill';
|
|
4
|
+
import { parseEvent, extractSessionId, extractResult, } from './stream-events.js';
|
|
5
|
+
export const SUPPORTED_AGENTS = [
|
|
6
|
+
'cursor',
|
|
7
|
+
'claude-code',
|
|
8
|
+
'gemini-cli',
|
|
9
|
+
'codex',
|
|
10
|
+
];
|
|
11
|
+
export function isSupportedAgent(id) {
|
|
12
|
+
return SUPPORTED_AGENTS.includes(id);
|
|
13
|
+
}
|
|
14
|
+
export function runAgent(options) {
|
|
15
|
+
const { agentPath, workspace, prompt, env, timeoutMs, model, resumeId, onEvent } = options;
|
|
16
|
+
const args = [
|
|
17
|
+
'-p', '--force', '--trust', '--approve-mcps',
|
|
18
|
+
'--workspace', workspace,
|
|
19
|
+
'--output-format', 'stream-json',
|
|
20
|
+
];
|
|
21
|
+
if (model) {
|
|
22
|
+
args.push('--model', model);
|
|
23
|
+
}
|
|
24
|
+
if (resumeId) {
|
|
25
|
+
args.push('--resume', resumeId);
|
|
26
|
+
}
|
|
27
|
+
args.push(prompt);
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
let child;
|
|
30
|
+
try {
|
|
31
|
+
child = spawn(agentPath, args, {
|
|
32
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
33
|
+
env: { ...process.env, ...env },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
reject(new Error(`Failed to spawn agent at "${agentPath}": ${err instanceof Error ? err.message : String(err)}`));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const events = [];
|
|
41
|
+
const stderrChunks = [];
|
|
42
|
+
let timedOut = false;
|
|
43
|
+
let timer;
|
|
44
|
+
child.on('error', (err) => {
|
|
45
|
+
if (timer)
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
reject(new Error(`Failed to spawn agent at "${agentPath}": ${err.message}`));
|
|
48
|
+
});
|
|
49
|
+
if (child.stderr) {
|
|
50
|
+
child.stderr.on('data', (chunk) => {
|
|
51
|
+
stderrChunks.push(chunk.toString());
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (child.stdout) {
|
|
55
|
+
const rl = createInterface({ input: child.stdout });
|
|
56
|
+
rl.on('line', (line) => {
|
|
57
|
+
const event = parseEvent(line);
|
|
58
|
+
if (event) {
|
|
59
|
+
events.push(event);
|
|
60
|
+
onEvent?.(event);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (timeoutMs != null && timeoutMs > 0) {
|
|
65
|
+
timer = setTimeout(() => {
|
|
66
|
+
timedOut = true;
|
|
67
|
+
abortAgent(child).catch(() => { });
|
|
68
|
+
}, timeoutMs);
|
|
69
|
+
}
|
|
70
|
+
child.on('close', (code) => {
|
|
71
|
+
if (timer)
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
const resultEvent = extractResult(events);
|
|
74
|
+
const parts = stderrChunks.slice();
|
|
75
|
+
if (timedOut) {
|
|
76
|
+
parts.push(`Agent timed out after ${timeoutMs}ms`);
|
|
77
|
+
}
|
|
78
|
+
if (code === 0 && !resultEvent) {
|
|
79
|
+
parts.push('Agent exited cleanly but produced no result event');
|
|
80
|
+
}
|
|
81
|
+
resolve({
|
|
82
|
+
sessionId: extractSessionId(events),
|
|
83
|
+
resultEvent,
|
|
84
|
+
events,
|
|
85
|
+
exitCode: code,
|
|
86
|
+
timedOut,
|
|
87
|
+
stderr: parts.join(''),
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
export async function abortAgent(child) {
|
|
93
|
+
if (child.pid == null)
|
|
94
|
+
return;
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
const killTimer = setTimeout(() => {
|
|
97
|
+
treeKill(child.pid, 'SIGKILL', () => resolve());
|
|
98
|
+
}, 5000);
|
|
99
|
+
treeKill(child.pid, 'SIGTERM', (err) => {
|
|
100
|
+
if (err) {
|
|
101
|
+
clearTimeout(killTimer);
|
|
102
|
+
treeKill(child.pid, 'SIGKILL', () => resolve());
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
child.on('exit', () => {
|
|
106
|
+
clearTimeout(killTimer);
|
|
107
|
+
resolve();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { PassThrough } from 'node:stream';
|
|
4
|
+
import { runAgent } from './agent-runner.js';
|
|
5
|
+
function createMockChildProcess() {
|
|
6
|
+
const child = new EventEmitter();
|
|
7
|
+
child.pid = 12345;
|
|
8
|
+
child.stdout = new PassThrough();
|
|
9
|
+
child.stderr = new PassThrough();
|
|
10
|
+
return child;
|
|
11
|
+
}
|
|
12
|
+
const spawnMock = vi.fn();
|
|
13
|
+
const treeKillMock = vi.fn();
|
|
14
|
+
vi.mock('node:child_process', async () => {
|
|
15
|
+
const actual = await vi.importActual('node:child_process');
|
|
16
|
+
return {
|
|
17
|
+
...actual,
|
|
18
|
+
spawn: ((...args) => spawnMock(...args)),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
vi.mock('tree-kill', () => {
|
|
22
|
+
return {
|
|
23
|
+
default: ((...args) => treeKillMock(...args)),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
describe('agent-runner spawn contract', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
spawnMock.mockReset();
|
|
29
|
+
treeKillMock.mockReset();
|
|
30
|
+
vi.useRealTimers();
|
|
31
|
+
});
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.useRealTimers();
|
|
34
|
+
});
|
|
35
|
+
it('spawns cursor-agent with stable args and prompt', async () => {
|
|
36
|
+
const child = createMockChildProcess();
|
|
37
|
+
spawnMock.mockReturnValue(child);
|
|
38
|
+
const p = runAgent({
|
|
39
|
+
agentPath: '/usr/bin/cursor-agent',
|
|
40
|
+
workspace: '/tmp/workspace',
|
|
41
|
+
prompt: '/gsd/execute-plan foo',
|
|
42
|
+
timeoutMs: 0,
|
|
43
|
+
});
|
|
44
|
+
child.emit('close', 0);
|
|
45
|
+
const result = await p;
|
|
46
|
+
expect(result.exitCode).toBe(0);
|
|
47
|
+
expect(result.timedOut).toBe(false);
|
|
48
|
+
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
49
|
+
const [agentPath, args, opts] = spawnMock.mock.calls[0];
|
|
50
|
+
expect(agentPath).toBe('/usr/bin/cursor-agent');
|
|
51
|
+
expect(args).toEqual([
|
|
52
|
+
'-p',
|
|
53
|
+
'--force',
|
|
54
|
+
'--trust',
|
|
55
|
+
'--approve-mcps',
|
|
56
|
+
'--workspace',
|
|
57
|
+
'/tmp/workspace',
|
|
58
|
+
'--output-format',
|
|
59
|
+
'stream-json',
|
|
60
|
+
'/gsd/execute-plan foo',
|
|
61
|
+
]);
|
|
62
|
+
expect(opts.stdio).toEqual(['pipe', 'pipe', 'pipe']);
|
|
63
|
+
});
|
|
64
|
+
it('appends --model and --resume flags when provided', async () => {
|
|
65
|
+
const child = createMockChildProcess();
|
|
66
|
+
spawnMock.mockReturnValue(child);
|
|
67
|
+
const p = runAgent({
|
|
68
|
+
agentPath: 'cursor-agent',
|
|
69
|
+
workspace: '/w',
|
|
70
|
+
prompt: 'hello',
|
|
71
|
+
model: 'gpt-5',
|
|
72
|
+
resumeId: 'resume-123',
|
|
73
|
+
timeoutMs: 0,
|
|
74
|
+
});
|
|
75
|
+
child.emit('close', 0);
|
|
76
|
+
await p;
|
|
77
|
+
const [, args] = spawnMock.mock.calls[0];
|
|
78
|
+
expect(args).toEqual([
|
|
79
|
+
'-p',
|
|
80
|
+
'--force',
|
|
81
|
+
'--trust',
|
|
82
|
+
'--approve-mcps',
|
|
83
|
+
'--workspace',
|
|
84
|
+
'/w',
|
|
85
|
+
'--output-format',
|
|
86
|
+
'stream-json',
|
|
87
|
+
'--model',
|
|
88
|
+
'gpt-5',
|
|
89
|
+
'--resume',
|
|
90
|
+
'resume-123',
|
|
91
|
+
'hello',
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
it('merges env passthrough with provided env', async () => {
|
|
95
|
+
const child = createMockChildProcess();
|
|
96
|
+
spawnMock.mockReturnValue(child);
|
|
97
|
+
const p = runAgent({
|
|
98
|
+
agentPath: 'cursor-agent',
|
|
99
|
+
workspace: '/w',
|
|
100
|
+
prompt: 'hello',
|
|
101
|
+
env: { FOO: 'bar' },
|
|
102
|
+
timeoutMs: 0,
|
|
103
|
+
});
|
|
104
|
+
child.emit('close', 0);
|
|
105
|
+
await p;
|
|
106
|
+
const [, , opts] = spawnMock.mock.calls[0];
|
|
107
|
+
expect(opts.env).toMatchObject({ FOO: 'bar' });
|
|
108
|
+
});
|
|
109
|
+
it('aborts on timeout and resolves with timedOut: true', async () => {
|
|
110
|
+
vi.useFakeTimers();
|
|
111
|
+
const child = createMockChildProcess();
|
|
112
|
+
spawnMock.mockReturnValue(child);
|
|
113
|
+
// Simulate tree-kill completing immediately.
|
|
114
|
+
treeKillMock.mockImplementation(((_pid, _signal, cb) => cb?.(null)));
|
|
115
|
+
const promise = runAgent({
|
|
116
|
+
agentPath: 'cursor-agent',
|
|
117
|
+
workspace: '/w',
|
|
118
|
+
prompt: 'hello',
|
|
119
|
+
timeoutMs: 10,
|
|
120
|
+
});
|
|
121
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
122
|
+
// runAgent only resolves after close; emit it after timeout fires.
|
|
123
|
+
child.emit('close', 0);
|
|
124
|
+
const result = await promise;
|
|
125
|
+
expect(result.timedOut).toBe(true);
|
|
126
|
+
expect(treeKillMock).toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { SUPPORTED_AGENTS, isSupportedAgent } from './agent-runner.js';
|
|
3
|
+
describe('agent-runner', () => {
|
|
4
|
+
describe('SUPPORTED_AGENTS', () => {
|
|
5
|
+
it('includes cursor, claude-code, gemini-cli, codex', () => {
|
|
6
|
+
expect(SUPPORTED_AGENTS).toContain('cursor');
|
|
7
|
+
expect(SUPPORTED_AGENTS).toContain('claude-code');
|
|
8
|
+
expect(SUPPORTED_AGENTS).toContain('gemini-cli');
|
|
9
|
+
expect(SUPPORTED_AGENTS).toContain('codex');
|
|
10
|
+
expect(SUPPORTED_AGENTS).toHaveLength(4);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
describe('isSupportedAgent', () => {
|
|
14
|
+
it('returns true for supported agents', () => {
|
|
15
|
+
expect(isSupportedAgent('cursor')).toBe(true);
|
|
16
|
+
expect(isSupportedAgent('claude-code')).toBe(true);
|
|
17
|
+
expect(isSupportedAgent('gemini-cli')).toBe(true);
|
|
18
|
+
expect(isSupportedAgent('codex')).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
it('returns false for invalid agents', () => {
|
|
21
|
+
expect(isSupportedAgent('bogus-agent')).toBe(false);
|
|
22
|
+
expect(isSupportedAgent('')).toBe(false);
|
|
23
|
+
expect(isSupportedAgent('Cursor')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AutopilotConfig } from '../config.js';
|
|
2
|
+
export interface ResolvedEnvironment {
|
|
3
|
+
isWsl: boolean;
|
|
4
|
+
cursorBinaryPath: string;
|
|
5
|
+
clipExePath: string | null;
|
|
6
|
+
workspace: {
|
|
7
|
+
wslPath: string;
|
|
8
|
+
windowsPath: string | null;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export declare function applyWslBootstrap(config: AutopilotConfig): ResolvedEnvironment;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { isWsl } from '../config/wsl.js';
|
|
2
|
+
import { getClipExePath, getCursorBinaryPath, getWorkspaceDisplayPath, } from '../config/paths.js';
|
|
3
|
+
export function applyWslBootstrap(config) {
|
|
4
|
+
const isWslEnv = isWsl();
|
|
5
|
+
const cursorBinaryPath = getCursorBinaryPath(config);
|
|
6
|
+
const clipExePath = getClipExePath();
|
|
7
|
+
const workspace = getWorkspaceDisplayPath(config.workspaceRoot);
|
|
8
|
+
return {
|
|
9
|
+
isWsl: isWslEnv,
|
|
10
|
+
cursorBinaryPath,
|
|
11
|
+
clipExePath,
|
|
12
|
+
workspace,
|
|
13
|
+
};
|
|
14
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function main(): void;
|