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.
Files changed (83) hide show
  1. package/README.md +263 -0
  2. package/bin/gsd-unsupervised +3 -0
  3. package/bin/start-daemon.sh +12 -0
  4. package/bin/unsupervised-gsd +2 -0
  5. package/dist/agent-runner.d.ts +26 -0
  6. package/dist/agent-runner.js +111 -0
  7. package/dist/agent-runner.spawn.test.d.ts +1 -0
  8. package/dist/agent-runner.spawn.test.js +128 -0
  9. package/dist/agent-runner.test.d.ts +1 -0
  10. package/dist/agent-runner.test.js +26 -0
  11. package/dist/bootstrap/wsl-bootstrap.d.ts +11 -0
  12. package/dist/bootstrap/wsl-bootstrap.js +14 -0
  13. package/dist/cli.d.ts +1 -0
  14. package/dist/cli.js +172 -0
  15. package/dist/config/paths.d.ts +8 -0
  16. package/dist/config/paths.js +36 -0
  17. package/dist/config/wsl.d.ts +4 -0
  18. package/dist/config/wsl.js +43 -0
  19. package/dist/config.d.ts +79 -0
  20. package/dist/config.js +95 -0
  21. package/dist/config.test.d.ts +1 -0
  22. package/dist/config.test.js +27 -0
  23. package/dist/cursor-agent.d.ts +17 -0
  24. package/dist/cursor-agent.invoker.test.d.ts +1 -0
  25. package/dist/cursor-agent.invoker.test.js +150 -0
  26. package/dist/cursor-agent.js +156 -0
  27. package/dist/cursor-agent.test.d.ts +1 -0
  28. package/dist/cursor-agent.test.js +60 -0
  29. package/dist/daemon.d.ts +17 -0
  30. package/dist/daemon.js +374 -0
  31. package/dist/git.d.ts +23 -0
  32. package/dist/git.js +76 -0
  33. package/dist/goals.d.ts +34 -0
  34. package/dist/goals.js +148 -0
  35. package/dist/gsd-state.d.ts +49 -0
  36. package/dist/gsd-state.js +76 -0
  37. package/dist/init-wizard.d.ts +5 -0
  38. package/dist/init-wizard.js +96 -0
  39. package/dist/lifecycle.d.ts +41 -0
  40. package/dist/lifecycle.js +103 -0
  41. package/dist/lifecycle.test.d.ts +1 -0
  42. package/dist/lifecycle.test.js +116 -0
  43. package/dist/logger.d.ts +12 -0
  44. package/dist/logger.js +31 -0
  45. package/dist/notifier.d.ts +6 -0
  46. package/dist/notifier.js +37 -0
  47. package/dist/orchestrator.d.ts +35 -0
  48. package/dist/orchestrator.js +791 -0
  49. package/dist/resource-governor.d.ts +54 -0
  50. package/dist/resource-governor.js +57 -0
  51. package/dist/resource-governor.test.d.ts +1 -0
  52. package/dist/resource-governor.test.js +33 -0
  53. package/dist/resume-pointer.d.ts +36 -0
  54. package/dist/resume-pointer.js +116 -0
  55. package/dist/roadmap-parser.d.ts +24 -0
  56. package/dist/roadmap-parser.js +105 -0
  57. package/dist/roadmap-parser.test.d.ts +1 -0
  58. package/dist/roadmap-parser.test.js +57 -0
  59. package/dist/session-log.d.ts +53 -0
  60. package/dist/session-log.js +92 -0
  61. package/dist/session-log.test.d.ts +1 -0
  62. package/dist/session-log.test.js +146 -0
  63. package/dist/state-index.d.ts +5 -0
  64. package/dist/state-index.js +31 -0
  65. package/dist/state-parser.d.ts +13 -0
  66. package/dist/state-parser.js +82 -0
  67. package/dist/state-parser.test.d.ts +1 -0
  68. package/dist/state-parser.test.js +228 -0
  69. package/dist/state-types.d.ts +20 -0
  70. package/dist/state-types.js +1 -0
  71. package/dist/state-watcher.d.ts +49 -0
  72. package/dist/state-watcher.js +148 -0
  73. package/dist/status-server.d.ts +112 -0
  74. package/dist/status-server.js +379 -0
  75. package/dist/status-server.test.d.ts +1 -0
  76. package/dist/status-server.test.js +206 -0
  77. package/dist/stream-events.d.ts +423 -0
  78. package/dist/stream-events.js +87 -0
  79. package/dist/stream-events.test.d.ts +1 -0
  80. package/dist/stream-events.test.js +304 -0
  81. package/dist/todos-api.d.ts +5 -0
  82. package/dist/todos-api.js +35 -0
  83. 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,3 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/cli.js';
3
+
@@ -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,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/cli.js';
@@ -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;