mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40b4aba043 | ||
|
|
4b44988bfc | ||
|
|
316d0a4c82 | ||
|
|
1184720648 | ||
|
|
b067aad9b6 | ||
|
|
19a3d7c773 | ||
|
|
085f4acb60 | ||
|
|
091df2b6d8 | ||
|
|
5f775b1ab1 | ||
|
|
da51193264 |
@@ -1,5 +1,34 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Daemon mode and service install, plus subagent hook hardening and terminal/idle-checker fixes.
|
||||
|
||||
**New: run Codeman in the background without a terminal (#239, closes #231)**
|
||||
- `codeman web -d` starts the server detached: it survives closing the shell, logs to `~/.codeman/web.log`, records a pidfile, and only reports success after the server actually answers `/api/status` (a port clash or missing dependency can never read as a clean start). `codeman web --status` and `codeman web --stop` manage it; `--stop` verifies the pid still looks like a Codeman server before signalling, so a recycled pid is never SIGTERMed.
|
||||
- `codeman service install` / `status` / `uninstall`: installs a systemd user unit (Linux) or LaunchAgent (macOS) so the server comes back after reboots. The unit carries the installing shell's PATH (launchd's default PATH finds neither an nvm/Homebrew `node` nor `tmux`/`claude`), never contains `CODEMAN_PASSWORD`, and uses the same instance-scoped unit names as `install.sh` and the self-updater so no second copy can end up supervised.
|
||||
- Both refuse to start a second server on one data dir (pidfile check plus a live probe): two servers on the shared tmux socket would attach to each other's sessions.
|
||||
- Why `-d` exists at all: `nohup` does not protect a Node process, Node re-arms SIGHUP even when it inherits "ignore", so `nohup codeman web &` still dies on HUP. The detached relaunch (setsid) removes the controlling terminal instead.
|
||||
|
||||
**Subagent background-work hooks (#233, thanks @Lint111)**
|
||||
- The background Bash rewake helper now also watches the top-level parent transcript when the hook fires inside a subagent: Claude records a subagent's Bash result in its own `subagents/agent-*.jsonl` but queues the completion in the lead session transcript, so subagents previously never woke. It can also inline a `CODEMAN_RESULT_BEGIN/END` marked report (up to 64 KiB) from the task output file into the wake feedback.
|
||||
- New SubagentStop guard: a subagent that still owns live Monitor or background Bash processes is kept working instead of publishing an intermediate progress line as its final report. Ownership is verified against live process descriptors on `tasks/<id>.output`, so stale transcript text alone never blocks, and the guard fails open on systems without `/proc`.
|
||||
- Existing cases self-heal to the new hooks on next launch.
|
||||
|
||||
**AI idle checker: stderr kept out of the verdict (#234, thanks @Lint111)**
|
||||
|
||||
The `claude -p` verdict command no longer merges stderr into the verdict file, where CLI warnings could turn a valid verdict into a parse error. On failures, the first 200 chars of stderr are attached to the diagnostic instead.
|
||||
|
||||
**Terminal: large final batches drain fully (#235, thanks @Lint111)**
|
||||
|
||||
A render-scheduling flag was cleared after the flush instead of before it, so when a large batch left a remainder behind, the remainder stayed unrendered until unrelated output arrived. This looked like truncated responses or shell commands that never finish. The flush now reschedules itself until the queue is empty.
|
||||
|
||||
**Docs and tests**
|
||||
- README documents daemon mode and service install.
|
||||
- Unique test port for the daemon-control suite.
|
||||
|
||||
## 1.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -74,7 +74,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.13.0 (must match `package.json`)
|
||||
**Version**: 1.14.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -109,6 +109,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| CI-equivalent test sweep | `npm run test:ci` (full suite minus browser/perf — see Testing) |
|
||||
| Production start | `npm run start` |
|
||||
| Production logs | `journalctl --user -u codeman-web -f` |
|
||||
| Detached server | `codeman web -d` (`--status`, `--stop`; pidfile+log at `dataPath('web.pid'/'web.log')`). ⚠ Refuses to start a 2nd server on one data dir — see Instance isolation |
|
||||
| Install/remove the service | `codeman service install` / `status` / `uninstall` (systemd user unit on Linux, LaunchAgent on macOS; names from `config/service-names.ts`) |
|
||||
|
||||
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 3 Playwright tests). Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing).
|
||||
|
||||
@@ -141,7 +143,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
| Domain | Key files | Notes |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| **Entry** | `src/index.ts`, `src/cli.ts` | |
|
||||
| **Entry** | `src/index.ts`, `src/cli.ts`, `daemon-control`, `service-installer`, `config/service-names` | The last three back `web -d` / `service install` |
|
||||
| **Session** | `src/session.ts` ★, `session-manager`, `session-auto-ops`, `session-cli-builder`, `session-task-cache`, `session-order` (pure), `session-pty-exit-breaker`, `usage-limit-patterns`, `usage-telemetry`; `src/services/unified-session-service.ts` | Pure/unit-tested helpers are split out of `session.ts` on purpose |
|
||||
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | |
|
||||
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
|
||||
@@ -212,6 +214,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for codex/claude ≥ 2.1.187 at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). `_logScrollRouting()` prints the routing decision and its inputs once per session — read it before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||
|
||||
**Detached start + service install** (issue #231): `codeman web -d` relaunches the SAME entry script with `detached:true` (setsid), so there is no controlling terminal and no shell job entry. ⚠️ `nohup` is NOT what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and `cli.ts` handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile check + `/api/status` probe): a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. ⚠️ Neither may report success it has not observed — the parent polls `/api/status` until the child answers or dies, since `launchctl load` and a clean spawn are both silent about a server that starts and immediately exits. `--stop` verifies the pid still LOOKS like a Codeman server (`ps -o command=`) before signalling, because pids get recycled. Unit/label names live in `config/service-names.ts` so install.sh, `detectSupervisor()` and `service install` cannot drift into supervising two copies; they are instance-scoped, and identical to the historical names for the default instance. `service install` bakes the installing shell's PATH into the unit (launchd gives a job `/usr/bin:/bin:/usr/sbin:/sbin`, which finds neither a Homebrew/nvm `node` nor `tmux`/`claude`) and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
||||
|
||||
**Self-update** (App Settings → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||
|
||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||
|
||||
@@ -85,9 +85,29 @@ codeman web --multiuser # named logins + per-user case spaces
|
||||
Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
|
||||
|
||||
<details>
|
||||
<summary><strong>Run as a background service</strong></summary>
|
||||
<summary><strong>Keep it running in the background</strong></summary>
|
||||
|
||||
The installer's final menu sets this up for you (option 2) and verifies the service actually comes up before claiming success. To configure it manually instead:
|
||||
To outlive the shell you started it in, without setting anything up:
|
||||
|
||||
```bash
|
||||
codeman web -d # detach; logs to ~/.codeman/web.log
|
||||
codeman web --status # is it up, and on which pid
|
||||
codeman web --stop # graceful SIGTERM; agents keep running in tmux
|
||||
```
|
||||
|
||||
`-d` waits until the server actually answers before reporting success, and refuses to start a second one on the same data dir (two servers sharing a tmux socket attach to each other's sessions).
|
||||
|
||||
To have it come back after a reboot, install it as a service instead. The installer's final menu does this for you (option 2); `codeman service` is the equivalent for an `npm i -g aicodeman` install:
|
||||
|
||||
```bash
|
||||
codeman service install # systemd user unit (Linux) or LaunchAgent (macOS)
|
||||
codeman service status
|
||||
codeman service uninstall
|
||||
```
|
||||
|
||||
`service install` writes the unit with your current PATH baked in, which matters more than it sounds: launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin`, so a Homebrew or nvm `node`, `tmux` or `claude` is invisible to a hand-written plist. It never copies `CODEMAN_PASSWORD` into the unit file; add that yourself if the service needs auth.
|
||||
|
||||
To write the unit by hand instead:
|
||||
|
||||
**Linux (systemd):**
|
||||
|
||||
@@ -220,6 +240,8 @@ codeman web # localhost:3000 (loopback only — safe defau
|
||||
codeman web --port 8080 # custom port (or set CODEMAN_PORT)
|
||||
codeman web --https # self-signed TLS (only needed for remote access)
|
||||
codeman web -H 0.0.0.0 # bind LAN — REQUIRES CODEMAN_PASSWORD (see Security)
|
||||
codeman web -d # detach: survives closing the shell (--status, --stop)
|
||||
codeman service install # systemd/launchd service: comes back after reboots
|
||||
```
|
||||
|
||||
Open the printed URL. The page is a single dashboard; everything below happens there.
|
||||
@@ -268,6 +290,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
### 7. Operate & maintain
|
||||
|
||||
- **App Settings** — model, effort, permission startup mode, theme/skin, notifications, display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language.
|
||||
- **Run it in the background** — `codeman web -d` detaches from your shell (`--status`, `--stop`); `codeman service install` makes it a systemd user unit / macOS LaunchAgent that survives reboots. Both verify the server actually answers before reporting success, and both refuse to start a second server on one data dir. See [Keep it running in the background](#quick-start---installation).
|
||||
- **Self-update** — git-clone installs update in place from **Settings → Updates**.
|
||||
- **Deploy your own changes** — see [Development](#development).
|
||||
|
||||
@@ -403,6 +426,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
|
||||
## More Features
|
||||
|
||||
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, or **Gemini** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
|
||||
@@ -140,6 +140,22 @@ Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write
|
||||
|
||||
**Away digest** (COD-41/#136): `GET /api/away-digest?range=&since=&until=&lastViewed=` aggregates "what happened while you were away" from the lifecycle log + run-summary events + live sessions + daily token stats + recently-completed subagents into needs-attention/completed/still-running/idle/informational sections. Pure aggregator in `web/away-digest.ts` (`resolveAwayDigestRange()` validates the window — `since-last-visit`/`1h`/`today`/`24h`/`custom`, server-local TZ; `buildAwayDigest()` classifies). Header-button modal in `panels-ui.js` (button hidden on phones — regression-guarded). ⚠️ Returns `{success:true,digest}` (a legacy raw-ish shape, consistent with the other raw GET handlers in `system-routes.ts` — `{entries}`/`{config}`/`{files}`/`getSystemStats()`); frontend + tests read `.digest`. Subagent lookback is a fixed 60-min window regardless of range.
|
||||
|
||||
### Detached start and service install
|
||||
|
||||
**`codeman web -d` / `codeman service install`** (issue #231). Two answers to "keep it running", split by how long: `-d` survives the shell, the service survives a reboot. `src/daemon-control.ts` and `src/service-installer.ts`, both splitting pure builders (argv, URLs, pidfile parsing, unit-file text) from the IO.
|
||||
|
||||
Why a flag at all, when `nohup codeman web &` looks like it should work: it does not reliably. **Node re-arms SIGHUP to its default disposition even when it inherits "ignore" from `nohup`** (verified: `nohup node script.js &` then `kill -HUP` prints "Hangup" and dies; `/proc/<pid>/status` shows SIGHUP absent from `SigIgn`, where `nohup sleep` has it set). `cli.ts` then adds a SIGHUP handler that shuts the server down gracefully, so a delivered HUP always stops it. What actually works is removing the shell's ability to send one: `disown` in the user's shell, or `detached: true` (setsid) here. zsh HUPs running jobs on exit by default, bash does not on a clean `exit` but does when it receives SIGHUP itself, which is why "does `&` survive?" gets opposite answers on macOS and Linux.
|
||||
|
||||
Invariants:
|
||||
|
||||
- **Never a second server on one data dir.** `-d` and `service install` both check the pidfile AND probe `/api/status` first, and refuse. This is the instance-isolation hazard, not politeness: a second instance on the shared `tmux -L codeman` socket discovers the first one's live sessions, attaches PTYs to them and resizes them (see [Instance isolation](#instance-isolation-and-the-multi-instance-attach-danger)).
|
||||
- **Never report success that was not observed.** The parent polls `/api/status` until the child answers or exits, then prints the URL or the tail of `web.log`. A `launchctl load`, a `systemctl enable --now` and a plain spawn are all silent about a server that starts and dies half a second later, which is why `install.sh` verifies too. The log is append-only across launches, so each start writes a separator line and the failure tail begins there.
|
||||
- **A 401 counts as up.** `CODEMAN_PASSWORD` gates `/api/status`, so requiring a 200 would make readiness detection fail on exactly the installs that took security advice. The body is still checked for `"success"` so an unrelated service squatting on the port is not mistaken for Codeman.
|
||||
- **`--stop` verifies identity before signalling.** Pids are recycled; a stale pidfile plus a blind `process.kill` is how a tool SIGTERMs someone's database. `ps -o command=` (portable to macOS) must still look like a Codeman web process. When `ps` itself fails, the pid is treated as ours rather than orphaning the pidfile.
|
||||
- **One source of truth for the job name.** `src/config/service-names.ts` holds the systemd unit name and launchd label used by `install.sh`, `detectSupervisor()` in self-update, and `service install`. Drift here is silent and bad: `service install` would supervise a SECOND copy alongside the installer's. The names are instance-scoped (`CODEMAN_INSTANCE=beta` → `codeman-web-beta.service` / `com.codeman.beta.web`) so a beta cannot overwrite the production unit, and are byte-identical to the historical names for the default instance.
|
||||
- **PATH is the reason hand-written units fail.** launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin` and systemd's user manager is nearly as bare, so a Homebrew or nvm `node`, `tmux` or `claude` is simply absent. The unit therefore carries the installing shell's PATH with the running node's directory in front; `node_modules/.bin` entries are dropped, since npx injects those for one command and they would outlive the checkout.
|
||||
- **No secrets in unit files.** `CODEMAN_PASSWORD` present in the installing shell is NOT copied into the plist/unit; the operator is told to add it. `CODEMAN_INSTANCE` IS copied, because without it a supervised beta would silently run against the production data dir and tmux socket.
|
||||
|
||||
### Self-update
|
||||
|
||||
**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
|
||||
|
||||
@@ -149,9 +149,17 @@ to Claude as a system reminder. This implies `"async": true`; ordinary async
|
||||
hooks do not wake an idle turn, and their output waits for the next interaction.
|
||||
|
||||
Codeman uses this on `PostToolUse(Bash)`: a self-contained Node helper extracts
|
||||
the background task ID from the Bash result, watches the session transcript for
|
||||
the matching completion notification, and exits 2. It does not send terminal
|
||||
input, so it cannot submit a user's partially written prompt.
|
||||
the background task ID from the Bash result, watches the originating transcript
|
||||
and, for subagents, the top-level parent transcript for the matching completion
|
||||
notification, and exits 2. Claude records a subagent's Bash result in its
|
||||
`subagents/agent-*.jsonl` file but queues completion in the lead session JSONL.
|
||||
The task ID keeps each wake targeted. The helper does not send terminal input,
|
||||
so it cannot submit a user's partially written prompt.
|
||||
|
||||
For script-dispatched Codex work, `codex-run.sh` writes the final response
|
||||
between `CODEMAN_RESULT_BEGIN/END` markers in the background task output. The
|
||||
rewake helper includes a maximum of 64 KiB of that report in its feedback. UI
|
||||
subagent discovery and dispatcher result delivery are separate contracts.
|
||||
|
||||
### Notification
|
||||
|
||||
@@ -219,6 +227,16 @@ Or to allow exit:
|
||||
|
||||
**Use Cases**: Control nested loops, verify subagent output.
|
||||
|
||||
The hook input includes `agent_id`, `agent_transcript_path`, and
|
||||
`last_assistant_message`. Like `Stop`, a command hook can return
|
||||
`{"decision":"block","reason":"..."}` to keep the subagent running and feed
|
||||
the reason back to it.
|
||||
|
||||
Codeman uses this to prevent premature reports from workers that still own live
|
||||
Monitor or background-Bash processes. It derives candidate task IDs from the
|
||||
subagent transcript, but requires a matching live Linux process descriptor for
|
||||
`tasks/<id>.output`; historical task text by itself is not treated as active.
|
||||
|
||||
### TeammateIdle
|
||||
|
||||
**When**: When an agent-team teammate is about to go idle.
|
||||
|
||||
@@ -43,38 +43,22 @@ const syncData = DEC_SYNC_START + data + DEC_SYNC_END;
|
||||
this.broadcast('session:terminal', { id: sessionId, data: syncData });
|
||||
```
|
||||
|
||||
## Client-Side Implementation (`app.js`)
|
||||
## Client-Side Implementation (`terminal-ui.js`)
|
||||
|
||||
### `batchTerminalWrite(data)`
|
||||
|
||||
1. Checks if flicker filter is enabled (optional, per-session)
|
||||
2. If flicker filter active: buffers screen-clear patterns (`ESC[2J`, `ESC[H ESC[J`, `ESC[nA`)
|
||||
3. Accumulates data in `pendingWrites`
|
||||
4. Schedules `requestAnimationFrame` if not already scheduled
|
||||
5. On rAF callback: checks for incomplete sync blocks (start without end)
|
||||
6. If incomplete: waits up to 50ms via `syncWaitTimeout`
|
||||
7. Calls `flushPendingWrites()` when complete
|
||||
|
||||
### `extractSyncSegments(data)`
|
||||
|
||||
- Parses DEC 2026 markers, returns array of content segments
|
||||
- Content before sync blocks returned as-is
|
||||
- Content inside sync blocks returned without markers
|
||||
- Incomplete blocks (start without end) returned with marker for next chunk
|
||||
4. Calls `_scheduleTerminalWriteFlush()` if no flush is pending
|
||||
5. The yielded callback clears its scheduled flag before calling `flushPendingWrites()`
|
||||
6. Large batches schedule their own next chunk until the queue is empty
|
||||
|
||||
### `flushPendingWrites()`
|
||||
|
||||
```javascript
|
||||
const segments = extractSyncSegments(this.pendingWrites);
|
||||
this.pendingWrites = ''; // Clear before writing
|
||||
for (const segment of segments) {
|
||||
if (segment && !segment.startsWith(DEC_SYNC_START)) {
|
||||
terminal.write(segment); // Skip incomplete blocks (start with marker)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: Segments starting with `DEC_SYNC_START` are incomplete blocks awaiting more data. These are skipped (discarded if timeout forces flush).
|
||||
- Joins the queued terminal data and passes DEC 2026 markers through to xterm.js 6, which handles synchronized output natively.
|
||||
- Writes at most 32KB per yield for Codex and 64KB for other modes.
|
||||
- Requeues the remainder and immediately schedules another safe yield. A final large response therefore drains without waiting for another SSE event.
|
||||
|
||||
### `chunkedTerminalWrite(buffer, chunkSize=128KB)`
|
||||
|
||||
@@ -116,17 +100,15 @@ When detected, buffers 50ms of subsequent output before flushing atomically.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Incomplete sync blocks**: 50ms timeout forces flush (content discarded to prevent freeze)
|
||||
- **Incomplete sync blocks**: xterm.js retains synchronized output until its closing marker
|
||||
- **Large buffers**: Chunked writing prevents UI freeze
|
||||
- **Server shutdown**: Skips batching via `_isStopping` flag
|
||||
- **Session switch**: Clears flicker filter state, pending writes, and sync timeout (prevents cross-session data bleed)
|
||||
- **SSE reconnect**: `handleInit()` clears all pending write state
|
||||
|
||||
**Trade-off:** If a sync block is split across SSE packets and the end marker doesn't arrive within 50ms, the incomplete content is discarded. This prioritizes responsiveness over completeness. In practice this is rare since the server always sends complete `SYNC_START...SYNC_END` pairs and SSE typically delivers them atomically.
|
||||
|
||||
## DEC Mode 2026 Compatibility
|
||||
|
||||
Terminals that natively support DEC 2026 will buffer and render atomically. Terminals that don't support it ignore the escape sequences harmlessly. xterm.js doesn't support DEC 2026 natively, so the client implements its own buffering by parsing the markers.
|
||||
Terminals that natively support DEC 2026 buffer and render atomically. Codeman uses xterm.js 6, so the client passes the markers through instead of parsing or discarding partial blocks.
|
||||
|
||||
**Supporting terminals:** WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
|
||||
|
||||
@@ -135,4 +117,4 @@ Terminals that natively support DEC 2026 will buffer and render atomically. Term
|
||||
| File | Key Functions |
|
||||
|------|---------------|
|
||||
| `src/web/server.ts` | `batchTerminalData()`, `flushTerminalBatches()`, `broadcast()` |
|
||||
| `src/web/public/app.js` | `batchTerminalWrite()`, `extractSyncSegments()`, `flushPendingWrites()`, `flushFlickerBuffer()`, `chunkedTerminalWrite()` |
|
||||
| `src/web/public/terminal-ui.js` | `batchTerminalWrite()`, `_scheduleTerminalWriteFlush()`, `flushPendingWrites()`, `flushFlickerBuffer()`, `chunkedTerminalWrite()` |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.13.0",
|
||||
"version": "1.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.13.0",
|
||||
"version": "1.14.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.13.0",
|
||||
"version": "1.14.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+31
-3
@@ -135,6 +135,7 @@ export abstract class AiCheckerBase<
|
||||
// Active check state
|
||||
protected checkMuxName: string | null = null;
|
||||
protected checkTempFile: string | null = null;
|
||||
protected checkStderrFile: string | null = null;
|
||||
protected checkPromptFile: string | null = null;
|
||||
protected checkPollTimer: NodeJS.Timeout | null = null;
|
||||
protected checkTimeoutTimer: NodeJS.Timeout | null = null;
|
||||
@@ -376,6 +377,7 @@ export abstract class AiCheckerBase<
|
||||
const shortId = this.sessionId.slice(0, 8);
|
||||
const timestamp = Date.now();
|
||||
this.checkTempFile = join(tmpdir(), `${this.tempFilePrefix}-${shortId}-${timestamp}.txt`);
|
||||
this.checkStderrFile = join(tmpdir(), `${this.tempFilePrefix}-stderr-${shortId}-${timestamp}.txt`);
|
||||
this.checkPromptFile = join(tmpdir(), `${this.tempFilePrefix}-prompt-${shortId}-${timestamp}.txt`);
|
||||
this.checkMuxName = `${this.muxNamePrefix}${shortId}`;
|
||||
|
||||
@@ -386,6 +388,7 @@ export abstract class AiCheckerBase<
|
||||
|
||||
// Ensure output temp file exists (empty) so we can poll it
|
||||
writeFileSync(this.checkTempFile, '');
|
||||
writeFileSync(this.checkStderrFile, '');
|
||||
|
||||
// Write prompt to file to avoid E2BIG error (argument list too long)
|
||||
// The prompt can be 16KB+ which exceeds shell argument limits
|
||||
@@ -396,7 +399,7 @@ export abstract class AiCheckerBase<
|
||||
const modelArg = `--model "${this.config.model.replace(/"/g, '\\"')}"`;
|
||||
const augmentedPath = getAugmentedPath();
|
||||
const claudeCmd = `cat "${this.checkPromptFile}" | claude -p ${modelArg} --output-format text`;
|
||||
const fullCmd = `export PATH="${augmentedPath}"; ${claudeCmd} > "${this.checkTempFile}" 2>&1; echo "${this.doneMarker}" >> "${this.checkTempFile}"; rm -f "${this.checkPromptFile}"`;
|
||||
const fullCmd = `export PATH="${augmentedPath}"; ${claudeCmd} > "${this.checkTempFile}" 2> "${this.checkStderrFile}"; echo "${this.doneMarker}" >> "${this.checkTempFile}"; rm -f "${this.checkPromptFile}"`;
|
||||
|
||||
// Spawn tmux session
|
||||
try {
|
||||
@@ -461,18 +464,32 @@ export abstract class AiCheckerBase<
|
||||
const output = content.replace(this.doneMarker, '').trim();
|
||||
|
||||
if (!output) {
|
||||
return this.createErrorResult(`Empty output from ${this.checkDescription}`, durationMs);
|
||||
const stderr = this.readStderrDiagnostic();
|
||||
const detail = stderr ? `: ${stderr}` : '';
|
||||
return this.createErrorResult(`Empty output from ${this.checkDescription}${detail}`, durationMs);
|
||||
}
|
||||
|
||||
// Delegate to subclass for verdict parsing
|
||||
const parsed = this.parseVerdict(output);
|
||||
if (!parsed) {
|
||||
return this.createErrorResult(`Could not parse verdict from: "${output.substring(0, 100)}"`, durationMs);
|
||||
const stderr = this.readStderrDiagnostic();
|
||||
const detail = stderr ? `; stderr: "${stderr}"` : '';
|
||||
return this.createErrorResult(`Could not parse verdict from: "${output.substring(0, 100)}"${detail}`, durationMs);
|
||||
}
|
||||
|
||||
return this.createResult(parsed.verdict, parsed.reasoning, durationMs);
|
||||
}
|
||||
|
||||
private readStderrDiagnostic(): string {
|
||||
if (!this.checkStderrFile || !existsSync(this.checkStderrFile)) return '';
|
||||
|
||||
try {
|
||||
return readFileSync(this.checkStderrFile, 'utf-8').trim().substring(0, 200);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupCheck(): void {
|
||||
// Clear poll timer
|
||||
if (this.checkPollTimer) {
|
||||
@@ -509,6 +526,17 @@ export abstract class AiCheckerBase<
|
||||
this.checkTempFile = null;
|
||||
}
|
||||
|
||||
if (this.checkStderrFile) {
|
||||
try {
|
||||
if (existsSync(this.checkStderrFile)) {
|
||||
unlinkSync(this.checkStderrFile);
|
||||
}
|
||||
} catch {
|
||||
// Best effort cleanup
|
||||
}
|
||||
this.checkStderrFile = null;
|
||||
}
|
||||
|
||||
if (this.checkPromptFile) {
|
||||
try {
|
||||
if (existsSync(this.checkPromptFile)) {
|
||||
|
||||
+173
-10
@@ -21,6 +21,9 @@ import { getRalphLoop } from './ralph-loop.js';
|
||||
import { getStore } from './state-store.js';
|
||||
import { getErrorMessage } from './types.js';
|
||||
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
|
||||
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
||||
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require('../package.json') as { version: string };
|
||||
@@ -572,10 +575,11 @@ program
|
||||
console.log('');
|
||||
});
|
||||
|
||||
// Web interface command
|
||||
program
|
||||
.command('web')
|
||||
.description('Start the web interface')
|
||||
// ============ Web / daemon / service Commands ============
|
||||
|
||||
/** Shared option set for the commands that can launch a web server. */
|
||||
function addWebLaunchOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.option('-H, --host <host>', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1')
|
||||
.option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000')
|
||||
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
||||
@@ -584,17 +588,116 @@ program
|
||||
'--allow-unauthenticated-network',
|
||||
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
|
||||
)
|
||||
.option('--multiuser', 'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)')
|
||||
.action(async (options) => {
|
||||
.option(
|
||||
'--multiuser',
|
||||
'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)'
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalize commander's strings into the shape daemon-control/service-installer take. */
|
||||
function toWebLaunchOptions(options: {
|
||||
host: string;
|
||||
port: string;
|
||||
https?: boolean;
|
||||
titleHostname?: string;
|
||||
allowUnauthenticatedNetwork?: boolean;
|
||||
multiuser?: boolean;
|
||||
}): WebLaunchOptions {
|
||||
const port = parseInt(options.port, 10);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
console.error(chalk.red(`✗ Invalid port: ${options.port}`));
|
||||
process.exit(1);
|
||||
}
|
||||
return {
|
||||
host: options.host,
|
||||
port,
|
||||
https: !!options.https,
|
||||
titleHostname: options.titleHostname,
|
||||
allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork,
|
||||
multiuser: !!options.multiuser,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The server prints this itself, but into a log file nobody reads when it is
|
||||
* detached or supervised. Repeat it where the operator is actually looking.
|
||||
*/
|
||||
function warnIfUnauthenticatedNetwork(launch: WebLaunchOptions): void {
|
||||
if (isLoopbackBindHost(launch.host)) return;
|
||||
if (isUnauthenticatedNetworkAcknowledged(launch.allowUnauthenticatedNetwork)) return;
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠ Binding ${launch.host} without CODEMAN_PASSWORD: anyone who can reach this port gets terminal control.`
|
||||
)
|
||||
);
|
||||
console.log(chalk.yellow(' Set CODEMAN_PASSWORD, or bind 127.0.0.1 and front it with tailscale serve.'));
|
||||
}
|
||||
|
||||
// Web interface command
|
||||
const webCmd = addWebLaunchOptions(program.command('web').description('Start the web interface'))
|
||||
.option('-d, --daemon', 'Run detached in the background; survives the shell, logs to <data dir>/web.log')
|
||||
.option('--stop', 'Stop a server started with --daemon')
|
||||
.option('--status', 'Report whether a detached server is running');
|
||||
|
||||
webCmd.action(async (options) => {
|
||||
// The flag is surfaced to the rest of the process via the env var so
|
||||
// isMultiUserMode() has a single source of truth (see config/multiuser.ts).
|
||||
if (options.multiuser) process.env.CODEMAN_MULTIUSER = '1';
|
||||
const launch = toWebLaunchOptions(options);
|
||||
|
||||
if (options.stop) {
|
||||
const result = await stopDaemon(launch);
|
||||
if (result.ok && result.reason === 'not-running') {
|
||||
console.log(chalk.gray(`○ ${result.message}`));
|
||||
return;
|
||||
}
|
||||
if (result.ok) {
|
||||
console.log(chalk.green(`✓ ${result.message ?? `Stopped Codeman (pid ${result.pid})`}`));
|
||||
console.log(chalk.gray(' Your agents keep running in tmux.'));
|
||||
return;
|
||||
}
|
||||
console.error(chalk.red(`✗ ${result.message ?? 'Could not stop the server'}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.status) {
|
||||
const status = await daemonStatus(launch);
|
||||
if (status.responding) {
|
||||
const version = status.version ? ` (v${status.version})` : '';
|
||||
console.log(chalk.green(`✓ Responding at ${status.url}${version}`));
|
||||
} else {
|
||||
console.log(chalk.yellow(`○ Nothing answering at ${status.url}`));
|
||||
}
|
||||
console.log(` Daemon pid: ${status.running ? chalk.green(String(status.pid)) : chalk.gray('not running')}`);
|
||||
console.log(chalk.gray(` Pidfile: ${status.pidFile}`));
|
||||
console.log(chalk.gray(` Log: ${status.logPath}`));
|
||||
if (!status.running && status.responding) {
|
||||
console.log(chalk.gray(' (running, but not started with --daemon: probably a service or a foreground run)'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.daemon) {
|
||||
warnIfUnauthenticatedNetwork(launch);
|
||||
console.log(chalk.cyan('Starting Codeman in the background...'));
|
||||
const result = await startDaemon(launch);
|
||||
if (result.ok) {
|
||||
console.log(chalk.green(`\n✓ Codeman is running at ${result.url} (pid ${result.pid})`));
|
||||
console.log(chalk.gray(` Logs: ${result.logPath}`));
|
||||
console.log(chalk.gray(' Stop it with: codeman web --stop'));
|
||||
console.log(chalk.gray(' Want it back after a reboot? codeman service install'));
|
||||
return;
|
||||
}
|
||||
console.error(chalk.red(`\n✗ ${result.message ?? 'Failed to start'}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { startWebServer } = await import('./web/server.js');
|
||||
const host = options.host;
|
||||
const port = parseInt(options.port, 10);
|
||||
const https = !!options.https;
|
||||
const host = launch.host;
|
||||
const port = launch.port;
|
||||
const https = launch.https;
|
||||
const titleHostname = options.titleHostname;
|
||||
const allowUnauthenticatedNetwork = !!options.allowUnauthenticatedNetwork;
|
||||
const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false;
|
||||
const protocol = https ? 'https' : 'http';
|
||||
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
||||
|
||||
@@ -630,6 +733,66 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Supervised service: the "still there after a reboot" answer, where `web -d` is
|
||||
// the "still there after I close this shell" one (issue #231).
|
||||
const serviceCmd = program
|
||||
.command('service')
|
||||
.description('Manage the background service (systemd user unit on Linux, LaunchAgent on macOS)');
|
||||
|
||||
addWebLaunchOptions(
|
||||
serviceCmd.command('install').description('Install and start the service, then verify it answers')
|
||||
).action(async (options) => {
|
||||
const launch = toWebLaunchOptions(options);
|
||||
warnIfUnauthenticatedNetwork(launch);
|
||||
console.log(chalk.cyan('Installing the Codeman service...'));
|
||||
|
||||
const result = await installService(launch);
|
||||
for (const warning of result.warnings ?? []) console.log(chalk.yellow(`⚠ ${warning}`));
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(chalk.red(`✗ ${result.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(chalk.green(`✓ ${result.message}`));
|
||||
console.log(chalk.gray(` Unit: ${result.unitPath}`));
|
||||
if (process.env.CODEMAN_PASSWORD) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Note: CODEMAN_PASSWORD was NOT copied into the unit file. Add it there yourself if the service needs auth.'
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
serviceCmd
|
||||
.command('uninstall')
|
||||
.description('Stop the service and remove its unit file')
|
||||
.action(() => {
|
||||
const result = uninstallService();
|
||||
if (!result.ok) {
|
||||
console.error(chalk.red(`✗ ${result.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(chalk.green(`✓ ${result.message}`));
|
||||
});
|
||||
|
||||
addWebLaunchOptions(
|
||||
serviceCmd.command('status').description('Show whether the service is installed and running')
|
||||
).action(async (options) => {
|
||||
const status = await serviceStatus(toWebLaunchOptions(options));
|
||||
if (!status.kind) {
|
||||
console.log(chalk.yellow(`No supported supervisor on ${process.platform}. Use \`codeman web -d\` instead.`));
|
||||
return;
|
||||
}
|
||||
console.log(` Supervisor: ${status.kind} (${status.name})`);
|
||||
console.log(` Unit file: ${status.installed ? chalk.green(status.unitPath) : chalk.gray('not installed')}`);
|
||||
console.log(` Loaded: ${status.loaded ? chalk.green('yes') : chalk.gray('no')}`);
|
||||
const version = status.version ? ` (v${status.version})` : '';
|
||||
console.log(
|
||||
` Responding: ${status.responding ? chalk.green(`yes at ${status.url}${version}`) : chalk.gray(`no at ${status.url}`)}`
|
||||
);
|
||||
});
|
||||
|
||||
// ============ Multi-user Commands ============
|
||||
//
|
||||
// Operate directly on ~/.codeman/users.json (via user-store) with NO running
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @fileoverview Supervisor identity (systemd unit name / launchd job label).
|
||||
*
|
||||
* Three things now write or look for the same supervisor job: `install.sh`, the
|
||||
* in-app self-updater (`web/self-update.ts` detects it to decide how to restart),
|
||||
* and `codeman service install`. The names live here so they cannot drift apart,
|
||||
* because a mismatch is silent in the worst way: `service install` would happily
|
||||
* create a SECOND job alongside the installer's, and two servers sharing one data
|
||||
* dir and one tmux socket attach PTYs to each other's live sessions
|
||||
* (see config/instance.ts).
|
||||
*
|
||||
* The names are instance-scoped for exactly that reason: a `CODEMAN_INSTANCE=beta`
|
||||
* build writing `com.codeman.web` would overwrite the production LaunchAgent. The
|
||||
* DEFAULT instance keeps the historical names byte-identical, so existing installs
|
||||
* and every unit install.sh has already written are unaffected.
|
||||
*
|
||||
* @module config/service-names
|
||||
*/
|
||||
|
||||
import { CODEMAN_INSTANCE } from './instance.js';
|
||||
|
||||
/**
|
||||
* Instance name reduced to characters that are safe in a filename and in a
|
||||
* launchd label. `CODEMAN_INSTANCE` is arbitrary operator input.
|
||||
*/
|
||||
const SAFE_INSTANCE = CODEMAN_INSTANCE.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32);
|
||||
|
||||
/** systemd user unit: `codeman-web.service`, or `codeman-web-beta.service` for a beta. */
|
||||
export const SYSTEMD_UNIT = `codeman-web${SAFE_INSTANCE ? `-${SAFE_INSTANCE}` : ''}.service`;
|
||||
|
||||
/** launchd job label: `com.codeman.web`, or `com.codeman.beta.web` for a beta. */
|
||||
export const LAUNCHD_LABEL = SAFE_INSTANCE ? `com.codeman.${SAFE_INSTANCE}.web` : 'com.codeman.web';
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* @fileoverview Detached `codeman web` control: start (-d), stop, status.
|
||||
*
|
||||
* Backs `codeman web -d`, `codeman web --stop` and `codeman web --status`. The
|
||||
* server itself is unchanged; this module re-launches the SAME entry script in a
|
||||
* new session (`detached: true` calls setsid), so the child has no controlling
|
||||
* terminal and no shell job entry. That is what actually makes it outlive the
|
||||
* shell: `nohup` does not, because Node re-arms SIGHUP to its default disposition
|
||||
* even when it inherits "ignore", and `cli.ts` installs a SIGHUP handler that
|
||||
* shuts the server down gracefully (issue #231).
|
||||
*
|
||||
* Two rules shape the rest of the module:
|
||||
*
|
||||
* 1. **Never start a second server on one data dir.** `~/.codeman` and the
|
||||
* `tmux -L codeman` socket are process-wide (config/instance.ts), so a second
|
||||
* instance discovers and attaches PTYs to the first one's live sessions and
|
||||
* starts resizing them. A double `-d` therefore has to be a hard error, which
|
||||
* means checking both the pidfile AND the port before spawning.
|
||||
* 2. **Never report success we have not seen.** The parent polls `/api/status`
|
||||
* until the child answers (or dies) before printing a URL. A port clash or a
|
||||
* missing dependency otherwise looks exactly like a clean start.
|
||||
*
|
||||
* Pure helpers (arg building, URL building, pidfile parsing, the process-identity
|
||||
* check) are exported separately so they can be unit-tested without spawning.
|
||||
*
|
||||
* @module daemon-control
|
||||
*/
|
||||
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
|
||||
/** How long to wait for a freshly spawned server to answer `/api/status`. */
|
||||
const START_TIMEOUT_MS = 30_000;
|
||||
/** How long to wait for a SIGTERM'd server to actually exit before giving up. */
|
||||
const STOP_TIMEOUT_MS = 15_000;
|
||||
/** Poll interval while waiting for either of the above. */
|
||||
const POLL_INTERVAL_MS = 250;
|
||||
|
||||
/** The `web` command's options, as far as a detached relaunch cares about them. */
|
||||
export interface WebLaunchOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
https: boolean;
|
||||
titleHostname?: string;
|
||||
allowUnauthenticatedNetwork?: boolean;
|
||||
multiuser?: boolean;
|
||||
}
|
||||
|
||||
export interface StartResult {
|
||||
ok: boolean;
|
||||
pid?: number;
|
||||
url?: string;
|
||||
/** Machine-readable failure cause; `undefined` on success. */
|
||||
reason?: 'already-running' | 'exited' | 'timeout';
|
||||
message?: string;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
export interface StopResult {
|
||||
ok: boolean;
|
||||
pid?: number;
|
||||
reason?: 'not-running' | 'foreign-pid' | 'timeout' | 'no-pidfile-but-responding';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DaemonStatus {
|
||||
pid: number | null;
|
||||
/** The pid in the pidfile is alive AND still looks like a Codeman web process. */
|
||||
running: boolean;
|
||||
/** Something answered `/api/status` at the expected address. */
|
||||
responding: boolean;
|
||||
version?: string;
|
||||
url: string;
|
||||
pidFile: string;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pure helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Rebuild the `web` argv for the child, dropping the daemon flags themselves. */
|
||||
export function buildWebArgs(options: WebLaunchOptions): string[] {
|
||||
const args = ['web', '--host', options.host, '--port', String(options.port)];
|
||||
if (options.https) args.push('--https');
|
||||
if (options.titleHostname) args.push('--title-hostname', options.titleHostname);
|
||||
if (options.allowUnauthenticatedNetwork) args.push('--allow-unauthenticated-network');
|
||||
if (options.multiuser) args.push('--multiuser');
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connectable address for this bind. A wildcard bind is not itself connectable,
|
||||
* so `0.0.0.0` / `::` become loopback; a bare IPv6 literal gets bracketed.
|
||||
*/
|
||||
export function buildBaseUrl(options: WebLaunchOptions): string {
|
||||
const protocol = options.https ? 'https' : 'http';
|
||||
let host = options.host.trim();
|
||||
if (host === '0.0.0.0' || host === '::' || host === '') host = '127.0.0.1';
|
||||
if (host.includes(':') && !host.startsWith('[')) host = `[${host}]`;
|
||||
return `${protocol}://${host}:${options.port}`;
|
||||
}
|
||||
|
||||
/** The endpoint polled for readiness. */
|
||||
export function buildStatusUrl(options: WebLaunchOptions): string {
|
||||
return `${buildBaseUrl(options)}/api/status`;
|
||||
}
|
||||
|
||||
/** Parse a pidfile body. Rejects garbage, and pid 1 (init is never ours). */
|
||||
export function parsePidFileContents(text: string): number | null {
|
||||
const trimmed = text.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return null;
|
||||
const pid = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isSafeInteger(pid) || pid <= 1) return null;
|
||||
return pid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this command line look like a Codeman web server?
|
||||
*
|
||||
* Pids are recycled, and a stale pidfile pointing at whatever inherited the
|
||||
* number is a live footgun: `codeman web --stop` must not SIGTERM an unrelated
|
||||
* process. Both the npm bin (`codeman`/`aicodeman`) and the direct entry
|
||||
* (`node dist/index.js web`, `tsx src/index.ts web`) have to match.
|
||||
*/
|
||||
export function looksLikeCodemanWeb(command: string | null | undefined): boolean {
|
||||
if (!command) return false;
|
||||
if (!/(^|\s)web(\s|$)/.test(command)) return false;
|
||||
return /(^|[/\s])(ai)?codeman(\s|$)/.test(command) || /index\.(js|ts)(\s|$)/.test(command);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Paths
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolved at call time, not module load: tests swap `HOME` per file, and the
|
||||
* data dir is derived from it (see test/setup.ts).
|
||||
*/
|
||||
export function pidFilePath(): string {
|
||||
return dataPath('web.pid');
|
||||
}
|
||||
|
||||
/** Where a detached server's stdout/stderr is appended. */
|
||||
export function logFilePath(): string {
|
||||
return dataPath('web.log');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Process probing
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Signal 0 liveness check. EPERM means the pid exists but is not ours. */
|
||||
export function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
/** Full command line of a pid, or null. `-o command=` is portable to macOS. */
|
||||
export function readProcessCommand(pid: number): string | null {
|
||||
try {
|
||||
const out = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
return out.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the pidfile, returning null when it is missing, empty or malformed. */
|
||||
export function readPidFile(): number | null {
|
||||
const file = pidFilePath();
|
||||
if (!existsSync(file)) return null;
|
||||
try {
|
||||
return parsePidFileContents(readFileSync(file, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removePidFile(): void {
|
||||
try {
|
||||
unlinkSync(pidFilePath());
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
/** Pid of a live Codeman web server recorded in the pidfile, or null. */
|
||||
export function readLivePid(): number | null {
|
||||
const pid = readPidFile();
|
||||
if (pid === null) return null;
|
||||
if (!isProcessAlive(pid)) return null;
|
||||
// A recycled pid is not ours. `ps` can also legitimately fail (containers with
|
||||
// no procps); treat "cannot tell" as ours rather than orphaning the pidfile.
|
||||
const command = readProcessCommand(pid);
|
||||
if (command !== null && !looksLikeCodemanWeb(command)) return null;
|
||||
return pid;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP readiness probe
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProbeResult {
|
||||
/** A Codeman server answered. A 401 counts: auth is active, the server is up. */
|
||||
up: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe `/api/status`. Self-signed certs are accepted (`--https` generates one),
|
||||
* and 401 counts as up because `CODEMAN_PASSWORD` gates that route. The body is
|
||||
* checked so an unrelated service squatting on the port is not read as success.
|
||||
*/
|
||||
export function probeServer(url: string, timeoutMs = 2000): Promise<ProbeResult> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const done = (result: ProbeResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(url);
|
||||
} catch {
|
||||
done({ up: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = target.protocol === 'https:' ? https : http;
|
||||
const req = transport.request(
|
||||
{
|
||||
protocol: target.protocol,
|
||||
hostname: target.hostname,
|
||||
port: target.port,
|
||||
path: target.pathname,
|
||||
method: 'GET',
|
||||
rejectUnauthorized: false,
|
||||
timeout: timeoutMs,
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
(res) => {
|
||||
if (res.statusCode === 401) {
|
||||
res.resume();
|
||||
done({ up: true });
|
||||
return;
|
||||
}
|
||||
let body = '';
|
||||
res.setEncoding('utf-8');
|
||||
res.on('data', (chunk: string) => {
|
||||
if (body.length < 4096) body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (!body.includes('"success"')) {
|
||||
done({ up: false });
|
||||
return;
|
||||
}
|
||||
let version: string | undefined;
|
||||
try {
|
||||
version = (JSON.parse(body) as { data?: { version?: string } }).data?.version;
|
||||
} catch {
|
||||
/* body was truncated at 4KB; up is still true */
|
||||
}
|
||||
done({ up: true, version });
|
||||
});
|
||||
res.on('error', () => done({ up: false }));
|
||||
}
|
||||
);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
done({ up: false });
|
||||
});
|
||||
req.on('error', () => done({ up: false }));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Start / stop / status
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The script to relaunch. `process.execArgv` is carried over with it so a dev
|
||||
* run under tsx (whose execArgv holds the tsx loader flags) re-launches through
|
||||
* tsx instead of handing a `.ts` file to bare node.
|
||||
*/
|
||||
function entryScript(): string {
|
||||
const script = process.argv[1];
|
||||
if (!script) throw new Error('cannot determine the codeman entry script to relaunch');
|
||||
return script;
|
||||
}
|
||||
|
||||
/** Marks one launch in the append-only log so a tail cannot mix two runs. */
|
||||
const LOG_SEPARATOR = '=== codeman web start';
|
||||
|
||||
/**
|
||||
* Last few lines of the daemon log, for reporting a failed start. The log is
|
||||
* append-only across launches, so the tail starts at the last separator when
|
||||
* there is one: otherwise a crash report is padded with the previous run's
|
||||
* cheerful startup banner.
|
||||
*/
|
||||
export function tailLog(maxLines = 15): string {
|
||||
try {
|
||||
const lines = readFileSync(logFilePath(), 'utf-8').trimEnd().split('\n');
|
||||
const start = lines.map((line) => line.startsWith(LOG_SEPARATOR)).lastIndexOf(true);
|
||||
const current = start === -1 ? lines : lines.slice(start + 1);
|
||||
return current.slice(-maxLines).join('\n');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a detached `codeman web` and wait until it answers before returning.
|
||||
* Refuses when a server is already up on this data dir (see rule 1 in the module
|
||||
* docblock).
|
||||
*/
|
||||
export async function startDaemon(options: WebLaunchOptions): Promise<StartResult> {
|
||||
const logPath = logFilePath();
|
||||
const url = buildBaseUrl(options);
|
||||
const statusUrl = buildStatusUrl(options);
|
||||
|
||||
const existingPid = readLivePid();
|
||||
if (existingPid !== null) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'already-running',
|
||||
pid: existingPid,
|
||||
logPath,
|
||||
message: `a Codeman server is already running (pid ${existingPid}). Stop it with \`codeman web --stop\` first.`,
|
||||
};
|
||||
}
|
||||
const alreadyServing = await probeServer(statusUrl, 1500);
|
||||
if (alreadyServing.up) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'already-running',
|
||||
logPath,
|
||||
url,
|
||||
message: `something is already serving ${url}. Two servers on one data dir attach to each other's tmux sessions, so refusing to start.`,
|
||||
};
|
||||
}
|
||||
// A pidfile that survived a crash: the process is gone, so it is just litter.
|
||||
if (readPidFile() !== null) removePidFile();
|
||||
|
||||
const args = buildWebArgs(options);
|
||||
try {
|
||||
appendFileSync(logPath, `\n${LOG_SEPARATOR} ${new Date().toISOString()} ===\n`, 'utf-8');
|
||||
} catch {
|
||||
/* the spawn below reports a genuinely unwritable log */
|
||||
}
|
||||
const logFd = openSync(logPath, 'a');
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [...process.execArgv, entryScript(), ...args], {
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
env: process.env,
|
||||
});
|
||||
} finally {
|
||||
closeSync(logFd);
|
||||
}
|
||||
|
||||
let exited = false;
|
||||
child.on('exit', () => {
|
||||
exited = true;
|
||||
});
|
||||
child.on('error', () => {
|
||||
exited = true;
|
||||
});
|
||||
|
||||
const pid = child.pid;
|
||||
if (pid === undefined) {
|
||||
return { ok: false, reason: 'exited', logPath, message: 'failed to spawn the server process' };
|
||||
}
|
||||
writeFileSync(pidFilePath(), `${pid}\n`, 'utf-8');
|
||||
|
||||
const deadline = Date.now() + START_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (exited) {
|
||||
removePidFile();
|
||||
child.unref();
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'exited',
|
||||
logPath,
|
||||
message: `the server exited during startup. Last lines of ${logPath}:\n${tailLog()}`,
|
||||
};
|
||||
}
|
||||
const probe = await probeServer(statusUrl, 1000);
|
||||
if (probe.up) {
|
||||
child.unref();
|
||||
return { ok: true, pid, url, logPath };
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
child.unref();
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'timeout',
|
||||
pid,
|
||||
url,
|
||||
logPath,
|
||||
message: `the server did not answer ${url} within ${START_TIMEOUT_MS / 1000}s. It may still be starting; check ${logPath}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** SIGTERM the recorded server and wait for it to actually exit. */
|
||||
export async function stopDaemon(options: WebLaunchOptions): Promise<StopResult> {
|
||||
const pid = readPidFile();
|
||||
if (pid === null) {
|
||||
const probe = await probeServer(buildStatusUrl(options), 1500);
|
||||
if (probe.up) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'no-pidfile-but-responding',
|
||||
message:
|
||||
'a server is responding but there is no pidfile, so it was not started with `-d`. If it is a service use `codeman service uninstall` (or stop the unit); otherwise `pkill -f "index.js web"`.',
|
||||
};
|
||||
}
|
||||
return { ok: true, reason: 'not-running', message: 'no daemon is running; nothing to stop' };
|
||||
}
|
||||
|
||||
if (!isProcessAlive(pid)) {
|
||||
removePidFile();
|
||||
return { ok: true, pid, message: `stale pidfile removed (pid ${pid} was not running)` };
|
||||
}
|
||||
|
||||
const command = readProcessCommand(pid);
|
||||
if (command !== null && !looksLikeCodemanWeb(command)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'foreign-pid',
|
||||
pid,
|
||||
message: `pid ${pid} is not a Codeman server (${command}). Refusing to signal it; delete ${pidFilePath()} if it is stale.`,
|
||||
};
|
||||
}
|
||||
|
||||
// SIGTERM, never SIGKILL: cli.ts flushes state on the way out.
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch (err) {
|
||||
return { ok: false, reason: 'foreign-pid', pid, message: `could not signal pid ${pid}: ${String(err)}` };
|
||||
}
|
||||
|
||||
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessAlive(pid)) {
|
||||
removePidFile();
|
||||
return { ok: true, pid };
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'timeout',
|
||||
pid,
|
||||
message: `pid ${pid} did not exit within ${STOP_TIMEOUT_MS / 1000}s. Force it with \`kill -9 ${pid}\` if you are sure.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Report on both halves: the recorded process, and whether the port answers. */
|
||||
export async function daemonStatus(options: WebLaunchOptions): Promise<DaemonStatus> {
|
||||
const url = buildBaseUrl(options);
|
||||
const pid = readPidFile();
|
||||
const probe = await probeServer(buildStatusUrl(options), 2000);
|
||||
return {
|
||||
pid,
|
||||
running: readLivePid() !== null,
|
||||
responding: probe.up,
|
||||
version: probe.version,
|
||||
url,
|
||||
pidFile: pidFilePath(),
|
||||
logPath: logFilePath(),
|
||||
};
|
||||
}
|
||||
+207
-28
@@ -10,13 +10,15 @@
|
||||
* Key exports:
|
||||
* - `generateHooksConfig()` — returns hooks object for settings.local.json
|
||||
* - `writeHooksConfig(casePath)` — writes hooks + env config to disk
|
||||
* - `ensureCodemanHooks(casePath)` — safely installs/updates hooks for a managed case
|
||||
* - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings
|
||||
*
|
||||
* Hook events generated: `idle_prompt`, `permission_prompt`, `elicitation_dialog`,
|
||||
* `stop`, `teammate_idle`, `task_completed`
|
||||
*
|
||||
* Hook categories: `Notification` (3 matchers), `Stop` (1), `TeammateIdle` (1),
|
||||
* `TaskCompleted` (1), `PostToolUse` (1 self-contained background Bash rewake)
|
||||
* Hook categories: `Notification` (3 matchers), `Stop` (1), `SubagentStop` (1),
|
||||
* `TeammateIdle` (1), `TaskCompleted` (1), `PostToolUse` (1 self-contained
|
||||
* background Bash rewake)
|
||||
*
|
||||
* @dependencies types (HookEventType), config/auth-config (HOOK_TIMEOUT_SECONDS)
|
||||
* @consumedby web/server (session creation), session-cli-builder (env setup)
|
||||
@@ -52,15 +54,19 @@ const BACKGROUND_WAKE_MARKER_PREFIX = 'CODEMAN_BACKGROUND_REWAKE_V';
|
||||
* changes: `refreshStaleCodemanHooks` treats the absence of the CURRENT marker as
|
||||
* stale, so healed cases pick up the new script on next launch.
|
||||
*/
|
||||
const BACKGROUND_WAKE_MARKER = `${BACKGROUND_WAKE_MARKER_PREFIX}2`;
|
||||
const BACKGROUND_WAKE_MARKER = `${BACKGROUND_WAKE_MARKER_PREFIX}3`;
|
||||
const SUBAGENT_STOP_GUARD_MARKER_PREFIX = 'CODEMAN_SUBAGENT_STOP_GUARD_V';
|
||||
const SUBAGENT_STOP_GUARD_MARKER = `${SUBAGENT_STOP_GUARD_MARKER_PREFIX}1`;
|
||||
const BACKGROUND_WAKE_TIMEOUT_SECONDS = 6 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Inline Node helper for Claude Code's `asyncRewake` hook.
|
||||
*
|
||||
* A background Bash tool returns immediately with a task ID, then Claude writes
|
||||
* its completion as a queue-operation in the transcript. Watching that durable
|
||||
* record avoids injecting terminal input (which could submit a user's draft).
|
||||
* its completion as a queue-operation in the top-level transcript. Subagent hooks
|
||||
* receive their own transcript path even though their completion is parent-owned,
|
||||
* so the helper watches both paths. Watching durable records avoids injecting
|
||||
* terminal input (which could submit a user's draft).
|
||||
* The helper is embedded in settings via `node -e`, so it has no script path
|
||||
* that can go stale after an install or plugin-cache cleanup.
|
||||
*
|
||||
@@ -72,8 +78,12 @@ const BACKGROUND_WAKE_TIMEOUT_SECONDS = 6 * 60 * 60;
|
||||
export function generateBackgroundWakeScript(): string {
|
||||
return [
|
||||
"const fs = require('node:fs');",
|
||||
"const path = require('node:path');",
|
||||
`const ${BACKGROUND_WAKE_MARKER} = true;`,
|
||||
`const deadline = Date.now() + ${BACKGROUND_WAKE_TIMEOUT_SECONDS} * 1000;`,
|
||||
"const RESULT_BEGIN = '=== CODEMAN_RESULT_BEGIN ===';",
|
||||
"const RESULT_END = '=== CODEMAN_RESULT_END ===';",
|
||||
'const MAX_RESULT_CHARS = 65536;',
|
||||
'let input = {};',
|
||||
"try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { process.exit(0); }",
|
||||
'function findTaskId(value) {',
|
||||
@@ -98,46 +108,164 @@ export function generateBackgroundWakeScript(): string {
|
||||
'const taskId = findTaskId(input.tool_response);',
|
||||
"const transcriptPath = typeof input.transcript_path === 'string' ? input.transcript_path : '';",
|
||||
'if (!taskId || !transcriptPath) process.exit(0);',
|
||||
'const transcriptPaths = [transcriptPath];',
|
||||
'const sessionDir = path.dirname(path.dirname(transcriptPath));',
|
||||
"if (typeof input.agent_id === 'string' && path.basename(path.dirname(transcriptPath)) === 'subagents' &&",
|
||||
" typeof input.session_id === 'string' && path.basename(sessionDir) === input.session_id) {",
|
||||
" transcriptPaths.push(sessionDir + '.jsonl');",
|
||||
'}',
|
||||
'const transcripts = [...new Set(transcriptPaths)].map((transcript) => {',
|
||||
' let position = 0;',
|
||||
'try { position = Math.max(0, fs.statSync(transcriptPath).size - 262144); } catch { process.exit(0); }',
|
||||
"let carry = '';",
|
||||
' try { position = Math.max(0, fs.statSync(transcript).size - 262144); } catch {}',
|
||||
" return { path: transcript, position, carry: '' };",
|
||||
'});',
|
||||
'if (!transcripts.some((transcript) => fs.existsSync(transcript.path))) process.exit(0);',
|
||||
'function readMarkedResult(outputPath) {',
|
||||
" if (!outputPath || !path.isAbsolute(outputPath) || path.basename(outputPath) !== taskId + '.output') return '';",
|
||||
" if (path.basename(path.dirname(outputPath)) !== 'tasks') return '';",
|
||||
' try {',
|
||||
' const size = fs.statSync(outputPath).size;',
|
||||
' const length = Math.min(size, MAX_RESULT_CHARS * 2);',
|
||||
' const buffer = Buffer.allocUnsafe(length);',
|
||||
" const fd = fs.openSync(outputPath, 'r');",
|
||||
' const bytes = fs.readSync(fd, buffer, 0, length, size - length);',
|
||||
' fs.closeSync(fd);',
|
||||
" const text = buffer.subarray(0, bytes).toString('utf8');",
|
||||
' const begin = text.lastIndexOf(RESULT_BEGIN);',
|
||||
' const end = text.indexOf(RESULT_END, begin + RESULT_BEGIN.length);',
|
||||
" if (begin < 0 || end < 0) return '';",
|
||||
' let result = text.slice(begin + RESULT_BEGIN.length, end).trim();',
|
||||
" if (!result) return '';",
|
||||
' if (result.length > MAX_RESULT_CHARS) {',
|
||||
' const half = Math.floor(MAX_RESULT_CHARS / 2);',
|
||||
" result = result.slice(0, half) + '\\n\\n[report truncated by Codeman]\\n\\n' + result.slice(-half);",
|
||||
' }',
|
||||
" return '\\n\\nCompleted task report:\\n<codeman-background-result>\\n' + result + '\\n</codeman-background-result>';",
|
||||
" } catch { return ''; }",
|
||||
'}',
|
||||
'function inspect(text) {',
|
||||
' for (const line of text.split(/\\r?\\n/)) {',
|
||||
' if (!line.includes(taskId)) continue;',
|
||||
' let entry;',
|
||||
' try { entry = JSON.parse(line); } catch { continue; }',
|
||||
" if (entry.type !== 'queue-operation' || typeof entry.content !== 'string') continue;",
|
||||
" if (entry.type !== 'queue-operation' || entry.operation !== 'enqueue' || typeof entry.content !== 'string') continue;",
|
||||
" if (!entry.content.includes('<task-id>' + taskId + '</task-id>')) continue;",
|
||||
' const status = entry.content.match(/<status>(completed|failed|killed|error)<\\/status>/i);',
|
||||
' if (!status) continue;',
|
||||
' const output = entry.content.match(/<output-file>([^<]+)<\\/output-file>/i);',
|
||||
" const location = output ? ' Read ' + output[1] + ' and' : '';",
|
||||
" console.error('Background command ' + taskId + ' ' + status[1].toLowerCase() + '.' + location + ' continue the task.');",
|
||||
" const outputPath = output ? output[1].trim() : '';",
|
||||
" const location = outputPath ? ' Read ' + outputPath + ' and' : '';",
|
||||
' const result = readMarkedResult(outputPath);',
|
||||
" console.error('Background command ' + taskId + ' ' + status[1].toLowerCase() + '.' + location + ' continue the task.' + result);",
|
||||
' process.exit(2);',
|
||||
' }',
|
||||
'}',
|
||||
'function poll() {',
|
||||
' if (Date.now() > deadline || process.ppid === 1) process.exit(0);',
|
||||
'function pollTranscript(transcript) {',
|
||||
' try {',
|
||||
' const size = fs.statSync(transcriptPath).size;',
|
||||
" if (size < position) { position = 0; carry = ''; }",
|
||||
' if (size > position) {',
|
||||
' const length = Math.min(size - position, 1048576);',
|
||||
' const size = fs.statSync(transcript.path).size;',
|
||||
" if (size < transcript.position) { transcript.position = 0; transcript.carry = ''; }",
|
||||
' if (size > transcript.position) {',
|
||||
' const length = Math.min(size - transcript.position, 1048576);',
|
||||
' const buffer = Buffer.allocUnsafe(length);',
|
||||
" const fd = fs.openSync(transcriptPath, 'r');",
|
||||
' const bytes = fs.readSync(fd, buffer, 0, length, position);',
|
||||
" const fd = fs.openSync(transcript.path, 'r');",
|
||||
' const bytes = fs.readSync(fd, buffer, 0, length, transcript.position);',
|
||||
' fs.closeSync(fd);',
|
||||
' position += bytes;',
|
||||
" carry = (carry + buffer.subarray(0, bytes).toString('utf8')).slice(-262144);",
|
||||
' inspect(carry);',
|
||||
' transcript.position += bytes;',
|
||||
" transcript.carry = (transcript.carry + buffer.subarray(0, bytes).toString('utf8')).slice(-262144);",
|
||||
' inspect(transcript.carry);',
|
||||
' }',
|
||||
' } catch {}',
|
||||
'}',
|
||||
'function poll() {',
|
||||
' if (Date.now() > deadline || process.ppid === 1) process.exit(0);',
|
||||
' for (const transcript of transcripts) pollTranscript(transcript);',
|
||||
' setTimeout(poll, 1000);',
|
||||
'}',
|
||||
'poll();',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a Claude subagent alive while its Monitor or background Bash work is live.
|
||||
* Claude otherwise can publish the worker's last progress sentence as an Agent
|
||||
* result when one watcher ends, even if other tracked tasks are still running.
|
||||
*/
|
||||
export function generateSubagentStopGuardScript(): string {
|
||||
return [
|
||||
"const fs = require('node:fs');",
|
||||
`const ${SUBAGENT_STOP_GUARD_MARKER} = true;`,
|
||||
'let input = {};',
|
||||
"try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { process.exit(0); }",
|
||||
"const transcriptPath = typeof input.agent_transcript_path === 'string' ? input.agent_transcript_path : '';",
|
||||
'if (!transcriptPath) process.exit(0);',
|
||||
'let text;',
|
||||
'try {',
|
||||
' const size = fs.statSync(transcriptPath).size;',
|
||||
' const length = Math.min(size, 16 * 1024 * 1024);',
|
||||
' const buffer = Buffer.allocUnsafe(length);',
|
||||
" const fd = fs.openSync(transcriptPath, 'r');",
|
||||
' const bytes = fs.readSync(fd, buffer, 0, length, size - length);',
|
||||
' fs.closeSync(fd);',
|
||||
" text = buffer.subarray(0, bytes).toString('utf8');",
|
||||
'} catch { process.exit(0); }',
|
||||
'const launched = new Set();',
|
||||
'const finished = new Set();',
|
||||
'function inspectToolResult(value) {',
|
||||
" const serialized = typeof value === 'string' ? value : JSON.stringify(value ?? '');",
|
||||
' for (const match of serialized.matchAll(/Command running in background with ID:\\s*([A-Za-z0-9_-]+)/gi)) launched.add(match[1]);',
|
||||
' for (const match of serialized.matchAll(/Monitor started \\(task ([A-Za-z0-9_-]+)/gi)) launched.add(match[1]);',
|
||||
'}',
|
||||
'function inspectNotifications(value) {',
|
||||
" if (typeof value !== 'string' || !value.includes('<task-notification>')) return;",
|
||||
' for (const match of value.matchAll(/<task-notification>([\\s\\S]*?)<\\/task-notification>/gi)) {',
|
||||
' const body = match[1];',
|
||||
' const id = body.match(/<task-id>([^<]+)<\\/task-id>/i);',
|
||||
' const status = body.match(/<status>(completed|failed|killed|error)<\\/status>/i);',
|
||||
' if (id && status) finished.add(id[1].trim());',
|
||||
' }',
|
||||
'}',
|
||||
'for (const line of text.split(/\\r?\\n/)) {',
|
||||
' let entry;',
|
||||
' try { entry = JSON.parse(line); } catch { continue; }',
|
||||
' const content = entry && entry.message ? entry.message.content : undefined;',
|
||||
' if (Array.isArray(content)) {',
|
||||
' for (const block of content) {',
|
||||
" if (block && block.type === 'tool_result') inspectToolResult(block.content);",
|
||||
" if (block && block.type === 'text') inspectNotifications(block.text);",
|
||||
' }',
|
||||
' } else {',
|
||||
' inspectNotifications(content);',
|
||||
' }',
|
||||
' inspectNotifications(entry && entry.content);',
|
||||
'}',
|
||||
'function findLiveTasks(candidates) {',
|
||||
' const live = new Set();',
|
||||
" if (candidates.size === 0 || !fs.existsSync('/proc')) return live;",
|
||||
' let processIds;',
|
||||
" try { processIds = fs.readdirSync('/proc').filter((name) => /^\\d+$/.test(name)); } catch { return live; }",
|
||||
' for (const processId of processIds) {',
|
||||
" for (const descriptor of ['0', '1', '2']) {",
|
||||
' let target;',
|
||||
" try { target = fs.readlinkSync('/proc/' + processId + '/fd/' + descriptor); } catch { continue; }",
|
||||
' const match = target.match(/[\\/]tasks[\\/]([A-Za-z0-9_-]+)\\.output(?: \\(deleted\\))?$/);',
|
||||
' if (match && candidates.has(match[1])) live.add(match[1]);',
|
||||
' }',
|
||||
' if (live.size === candidates.size) break;',
|
||||
' }',
|
||||
' return live;',
|
||||
'}',
|
||||
'const unfinished = new Set([...launched].filter((taskId) => !finished.has(taskId)));',
|
||||
'const active = [...findLiveTasks(unfinished)];',
|
||||
'if (active.length === 0) process.exit(0);',
|
||||
'const shown = active.slice(0, 8);',
|
||||
"const suffix = active.length > shown.length ? ' and ' + (active.length - shown.length) + ' more' : '';",
|
||||
'process.stdout.write(JSON.stringify({',
|
||||
" decision: 'block',",
|
||||
" reason: 'You still own active background work (' + shown.join(', ') + suffix + '). Do not return an intermediate progress message as your final report. Process the task notifications or keep actively polling until every task completes, then return one complete summary.',",
|
||||
'}));',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function withSettingsLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = settingsWriteLocks.get(path) ?? Promise.resolve();
|
||||
const run = prev.then(fn, fn); // run after the prior writer, regardless of its outcome
|
||||
@@ -204,6 +332,18 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
],
|
||||
SubagentStop: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node',
|
||||
args: ['-e', generateSubagentStopGuardScript()],
|
||||
timeout: HOOK_TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
TeammateIdle: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: curlCmd('teammate_idle'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
@@ -235,8 +375,12 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||
function isCodemanHookHandler(value: unknown): boolean {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
// Prefix, not the versioned marker: older script versions must still be ours.
|
||||
return serialized.includes('/api/hook-event') || serialized.includes(BACKGROUND_WAKE_MARKER_PREFIX);
|
||||
// Prefixes, not versioned markers: older script versions must still be ours.
|
||||
return (
|
||||
serialized.includes('/api/hook-event') ||
|
||||
serialized.includes(BACKGROUND_WAKE_MARKER_PREFIX) ||
|
||||
serialized.includes(SUBAGENT_STOP_GUARD_MARKER_PREFIX)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -430,6 +574,39 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures an explicitly managed case has the current Codeman hooks.
|
||||
*
|
||||
* Unlike `refreshStaleCodemanHooks`, this may add Codeman handlers to a valid
|
||||
* user-owned settings file. It is therefore reserved for case quick-starts,
|
||||
* where the user has explicitly asked Codeman to manage that workspace. A
|
||||
* malformed existing file is left untouched rather than replaced.
|
||||
*/
|
||||
export async function ensureCodemanHooks(casePath: string): Promise<void> {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
|
||||
existing = parsed as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return;
|
||||
}
|
||||
|
||||
const generated = generateHooksConfig();
|
||||
const hooks = mergeCodemanHooks(existing.hooks, generated.hooks);
|
||||
if (JSON.stringify(existing.hooks ?? {}) === JSON.stringify(hooks)) return;
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify({ ...existing, hooks }, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-heal a case's Codeman-owned hooks block.
|
||||
*
|
||||
@@ -437,10 +614,11 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||
* X-Codeman-Hook-Secret header was added (COD-54, 2026-06-10) keep hook curls in their
|
||||
* settings.local.json that POST to /api/hook-event WITHOUT the secret — which, once the
|
||||
* gate requires it unconditionally (COD-91), silently 401 on a password-protected install.
|
||||
* Older Codeman blocks also lack the background Bash async-rewake hook. A third stale
|
||||
* shape: hook curls without `-k`, which exit 60 on every --https/tailscale install (the
|
||||
* cert is self-signed), swallowed by the hooks' own `|| true` — all six hook events die
|
||||
* silently. Refresh any of these stale shapes on launch so existing cases heal.
|
||||
* Older Codeman blocks also lack the current background Bash async-rewake hook or the
|
||||
* SubagentStop guard. A further stale shape: hook curls without `-k`, which exit 60 on
|
||||
* every --https/tailscale install (the cert is self-signed), swallowed by the hooks'
|
||||
* own `|| true` — all six hook events die silently. Refresh any of these stale shapes
|
||||
* on launch so existing cases heal.
|
||||
*
|
||||
* Deliberately surgical: regenerates ONLY when settings.local.json already contains
|
||||
* Codeman's own hook curls (they target `/api/hook-event`) and they are stale. No-op
|
||||
@@ -467,7 +645,8 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
|
||||
// as a substring, so this cleanly identifies hook curls that die with exit 60
|
||||
// on a self-signed HTTPS install.
|
||||
const hasTlsFlaglessCurl = hooksJson.includes('curl -s -X POST');
|
||||
if (!isOurs || (hasSecret && hasBackgroundWake && !hasTlsFlaglessCurl)) return;
|
||||
const hasSubagentStopGuard = hooksJson.includes(SUBAGENT_STOP_GUARD_MARKER);
|
||||
if (!isOurs || (hasSecret && hasBackgroundWake && hasSubagentStopGuard && !hasTlsFlaglessCurl)) return;
|
||||
const generated = generateHooksConfig();
|
||||
const merged = {
|
||||
...existing,
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* @fileoverview `codeman service install|uninstall|status`: write and load the
|
||||
* systemd user unit (Linux) or LaunchAgent (macOS) that supervises `codeman web`.
|
||||
*
|
||||
* This is the "always running" half of issue #231, next to the "detached right
|
||||
* now" half in daemon-control.ts. `install.sh` already does this for people who
|
||||
* install with the one-liner; this exists for `npm i -g aicodeman` users, who
|
||||
* otherwise have to hand-write a plist.
|
||||
*
|
||||
* Two details are load-bearing and easy to get wrong by hand:
|
||||
*
|
||||
* - **PATH.** launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin` and systemd's
|
||||
* user manager is nearly as bare, so a Homebrew or nvm `node`, `tmux` or
|
||||
* `claude` is simply not found and sessions fail in a way that reads as a
|
||||
* Codeman bug. The unit therefore carries the PATH of the shell that ran the
|
||||
* install, with the running node's own directory in front.
|
||||
* - **The job name.** It is the one `install.sh` and the self-updater already use
|
||||
* (config/service-names.ts), so re-running install.sh later updates this unit
|
||||
* instead of supervising a second copy of the server.
|
||||
*
|
||||
* Secrets are deliberately NOT written here. `CODEMAN_PASSWORD` in the installing
|
||||
* shell is not copied into the unit; the caller is told where to add it instead,
|
||||
* because a unit file is long-lived, world-readable by default, and gets copied
|
||||
* into bug reports.
|
||||
*
|
||||
* The file writers are pure string builders so they can be unit-tested without
|
||||
* touching launchctl/systemctl.
|
||||
*
|
||||
* @module service-installer
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { homedir, userInfo } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { LAUNCHD_LABEL, SYSTEMD_UNIT } from './config/service-names.js';
|
||||
import { CODEMAN_INSTANCE } from './config/instance.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import {
|
||||
buildBaseUrl,
|
||||
buildStatusUrl,
|
||||
buildWebArgs,
|
||||
logFilePath,
|
||||
probeServer,
|
||||
type WebLaunchOptions,
|
||||
} from './daemon-control.js';
|
||||
|
||||
export type ServiceKind = 'launchd' | 'systemd';
|
||||
|
||||
/** Everything a unit file needs, resolved from the environment by the caller. */
|
||||
export interface ServicePlan {
|
||||
kind: ServiceKind;
|
||||
/** systemd unit filename or launchd label. */
|
||||
name: string;
|
||||
nodePath: string;
|
||||
/** Runner flags carried over from the current process (tsx loader in dev). */
|
||||
execArgv: string[];
|
||||
scriptPath: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
logPath: string;
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
export interface ServiceActionResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
/** Path of the unit/plist that was written or removed. */
|
||||
unitPath?: string;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface ServiceStatusResult {
|
||||
kind: ServiceKind | null;
|
||||
name: string;
|
||||
unitPath: string;
|
||||
installed: boolean;
|
||||
loaded: boolean;
|
||||
responding: boolean;
|
||||
version?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Directories worth having on PATH even when the installing shell lacked them. */
|
||||
const FALLBACK_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pure builders
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** XML text escaping for plist `<string>` values. */
|
||||
export function xmlEscape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* PATH for the supervised process: the running node's directory first (so an nvm
|
||||
* or Homebrew node is used rather than whatever the supervisor finds), then the
|
||||
* installing shell's PATH, then the fallbacks that are still missing.
|
||||
*
|
||||
* `node_modules/.bin` entries are dropped. npm and npx inject those for the
|
||||
* lifetime of one command, and baking a project's local bin dir into a unit file
|
||||
* that outlives the checkout is how a service ends up running a binary the
|
||||
* operator deleted months ago.
|
||||
*/
|
||||
export function buildServicePath(nodeDir: string, currentPath: string, home: string): string {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
const push = (dir: string) => {
|
||||
const trimmed = dir.trim();
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
if (/(^|\/)node_modules\/\.bin\/?$/.test(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
ordered.push(trimmed);
|
||||
};
|
||||
|
||||
push(nodeDir);
|
||||
for (const dir of currentPath.split(':')) push(dir);
|
||||
push(join(home, '.local', 'bin'));
|
||||
for (const dir of FALLBACK_PATH_DIRS) push(dir);
|
||||
return ordered.join(':');
|
||||
}
|
||||
|
||||
/** Environment written into the unit. Never includes secrets (see module docs). */
|
||||
export function buildServiceEnv(
|
||||
nodeDir: string,
|
||||
currentPath: string,
|
||||
home: string,
|
||||
lang?: string
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {
|
||||
PATH: buildServicePath(nodeDir, currentPath, home),
|
||||
HOME: home,
|
||||
LANG: lang || 'en_US.UTF-8',
|
||||
};
|
||||
if (CODEMAN_INSTANCE) env.CODEMAN_INSTANCE = CODEMAN_INSTANCE;
|
||||
return env;
|
||||
}
|
||||
|
||||
/** systemd accepts double-quoted values; escape the two characters that matter. */
|
||||
export function systemdQuote(value: string): string {
|
||||
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
export function buildLaunchAgentPlist(plan: ServicePlan): string {
|
||||
const programArguments = [plan.nodePath, ...plan.execArgv, plan.scriptPath, ...plan.args]
|
||||
.map((arg) => ` <string>${xmlEscape(arg)}</string>`)
|
||||
.join('\n');
|
||||
const environment = Object.entries(plan.env)
|
||||
.map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`)
|
||||
.join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${xmlEscape(plan.name)}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
${programArguments}
|
||||
</array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
${environment}
|
||||
</dict>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${xmlEscape(plan.workingDir)}</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${xmlEscape(plan.logPath)}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${xmlEscape(plan.logPath)}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildSystemdUnit(plan: ServicePlan): string {
|
||||
const execStart = [plan.nodePath, ...plan.execArgv, plan.scriptPath, ...plan.args]
|
||||
.map((arg) => (/[\s"'\\]/.test(arg) ? systemdQuote(arg) : arg))
|
||||
.join(' ');
|
||||
const environment = Object.entries(plan.env)
|
||||
.map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`)
|
||||
.join('\n');
|
||||
|
||||
return `[Unit]
|
||||
Description=Codeman Web Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${plan.workingDir}
|
||||
ExecStart=${execStart}
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# Agents keep running in tmux when the server restarts, so only signal the
|
||||
# server itself.
|
||||
KillMode=process
|
||||
${environment}
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=codeman
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
`;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Environment resolution
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function detectServiceKind(): ServiceKind | null {
|
||||
if (process.platform === 'darwin') return 'launchd';
|
||||
if (process.platform === 'linux') return 'systemd';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function unitPathFor(kind: ServiceKind): string {
|
||||
return kind === 'launchd'
|
||||
? join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`)
|
||||
: join(homedir(), '.config', 'systemd', 'user', SYSTEMD_UNIT);
|
||||
}
|
||||
|
||||
function entryScript(): string {
|
||||
const script = process.argv[1];
|
||||
if (!script) throw new Error('cannot determine the codeman entry script to supervise');
|
||||
return script;
|
||||
}
|
||||
|
||||
/** Resolve a full plan from the current process and the requested web options. */
|
||||
export function resolveServicePlan(kind: ServiceKind, options: WebLaunchOptions): ServicePlan {
|
||||
const home = homedir();
|
||||
return {
|
||||
kind,
|
||||
name: kind === 'launchd' ? LAUNCHD_LABEL : SYSTEMD_UNIT,
|
||||
nodePath: process.execPath,
|
||||
execArgv: [...process.execArgv],
|
||||
scriptPath: entryScript(),
|
||||
args: buildWebArgs(options),
|
||||
env: buildServiceEnv(dirname(process.execPath), process.env.PATH || '', home, process.env.LANG),
|
||||
logPath: logFilePath(),
|
||||
workingDir: home,
|
||||
};
|
||||
}
|
||||
|
||||
function run(command: string, args: string[]): { ok: boolean; output: string } {
|
||||
try {
|
||||
const output = execFileSync(command, args, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { ok: true, output: output.trim() };
|
||||
} catch (err) {
|
||||
const e = err as { stderr?: Buffer | string; message?: string };
|
||||
const stderr = typeof e.stderr === 'string' ? e.stderr : e.stderr?.toString('utf-8');
|
||||
return { ok: false, output: (stderr || e.message || '').trim() };
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Install / uninstall / status
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write the unit, load it, and confirm the server actually answers before
|
||||
* reporting success. `launchctl load` and `systemctl enable` are both quiet about
|
||||
* a job that starts and immediately dies, which is the whole reason install.sh
|
||||
* verifies too.
|
||||
*/
|
||||
export async function installService(options: WebLaunchOptions): Promise<ServiceActionResult> {
|
||||
const kind = detectServiceKind();
|
||||
if (!kind) {
|
||||
return { ok: false, message: `no supported supervisor on ${process.platform}; use \`codeman web -d\` instead` };
|
||||
}
|
||||
|
||||
const plan = resolveServicePlan(kind, options);
|
||||
const unitPath = unitPathFor(kind);
|
||||
const warnings: string[] = [];
|
||||
mkdirSync(dirname(unitPath), { recursive: true });
|
||||
|
||||
if (kind === 'launchd') {
|
||||
const uid = process.getuid?.() ?? 0;
|
||||
// Unload any previous copy first, otherwise bootstrap fails with "service
|
||||
// already loaded" and leaves the OLD job running against the NEW file.
|
||||
run('launchctl', ['bootout', `gui/${uid}/${LAUNCHD_LABEL}`]);
|
||||
writeFileSync(unitPath, buildLaunchAgentPlist(plan), { encoding: 'utf-8', mode: 0o600 });
|
||||
const bootstrap = run('launchctl', ['bootstrap', `gui/${uid}`, unitPath]);
|
||||
if (!bootstrap.ok) {
|
||||
const legacy = run('launchctl', ['load', unitPath]);
|
||||
if (!legacy.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
unitPath,
|
||||
message: `wrote ${unitPath} but launchctl refused to load it: ${bootstrap.output}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
writeFileSync(unitPath, buildSystemdUnit(plan), { encoding: 'utf-8', mode: 0o600 });
|
||||
const reload = run('systemctl', ['--user', 'daemon-reload']);
|
||||
if (!reload.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
unitPath,
|
||||
message: `wrote ${unitPath} but \`systemctl --user daemon-reload\` failed: ${reload.output}`,
|
||||
};
|
||||
}
|
||||
const enable = run('systemctl', ['--user', 'enable', '--now', SYSTEMD_UNIT]);
|
||||
if (!enable.ok) {
|
||||
return { ok: false, unitPath, message: `wrote ${unitPath} but enabling it failed: ${enable.output}` };
|
||||
}
|
||||
// Without lingering the unit stops at logout, which is exactly what someone
|
||||
// installing a service does not want. Best effort: it needs polkit rights.
|
||||
const linger = run('loginctl', ['enable-linger', userInfo().username]);
|
||||
if (!linger.ok) {
|
||||
warnings.push(
|
||||
`could not enable lingering, so the service will stop when you log out. Run: sudo loginctl enable-linger ${userInfo().username}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const url = buildBaseUrl(options);
|
||||
const statusUrl = buildStatusUrl(options);
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const probe = await probeServer(statusUrl, 1000);
|
||||
if (probe.up) {
|
||||
return { ok: true, unitPath, warnings, message: `service installed and responding at ${url}` };
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
const hint =
|
||||
kind === 'launchd' ? `tail -20 ${plan.logPath}` : `journalctl --user -u ${SYSTEMD_UNIT} -n 20 --no-pager`;
|
||||
return {
|
||||
ok: false,
|
||||
unitPath,
|
||||
warnings,
|
||||
message: `wrote and loaded ${unitPath}, but nothing answered ${url} within 30s. Check: ${hint}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function uninstallService(): ServiceActionResult {
|
||||
const kind = detectServiceKind();
|
||||
if (!kind) return { ok: false, message: `no supported supervisor on ${process.platform}` };
|
||||
|
||||
const unitPath = unitPathFor(kind);
|
||||
if (!existsSync(unitPath)) {
|
||||
return { ok: false, unitPath, message: `no service installed at ${unitPath}` };
|
||||
}
|
||||
|
||||
if (kind === 'launchd') {
|
||||
const uid = process.getuid?.() ?? 0;
|
||||
const bootout = run('launchctl', ['bootout', `gui/${uid}/${LAUNCHD_LABEL}`]);
|
||||
if (!bootout.ok) run('launchctl', ['unload', unitPath]);
|
||||
} else {
|
||||
run('systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT]);
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(unitPath);
|
||||
} catch (err) {
|
||||
return { ok: false, unitPath, message: `stopped the service but could not remove ${unitPath}: ${String(err)}` };
|
||||
}
|
||||
if (kind === 'systemd') run('systemctl', ['--user', 'daemon-reload']);
|
||||
|
||||
return { ok: true, unitPath, message: `service stopped and ${unitPath} removed. Your tmux sessions are untouched.` };
|
||||
}
|
||||
|
||||
export async function serviceStatus(options: WebLaunchOptions): Promise<ServiceStatusResult> {
|
||||
const kind = detectServiceKind();
|
||||
const url = buildBaseUrl(options);
|
||||
if (!kind) {
|
||||
return { kind: null, name: '', unitPath: '', installed: false, loaded: false, responding: false, url };
|
||||
}
|
||||
|
||||
const unitPath = unitPathFor(kind);
|
||||
const name = kind === 'launchd' ? LAUNCHD_LABEL : SYSTEMD_UNIT;
|
||||
const installed = existsSync(unitPath);
|
||||
const loaded =
|
||||
kind === 'launchd'
|
||||
? run('launchctl', ['list', LAUNCHD_LABEL]).ok
|
||||
: run('systemctl', ['--user', 'is-active', SYSTEMD_UNIT]).output === 'active';
|
||||
const probe = await probeServer(buildStatusUrl(options), 2000);
|
||||
|
||||
return { kind, name, unitPath, installed, loaded, responding: probe.up, version: probe.version, url };
|
||||
}
|
||||
@@ -341,7 +341,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// WebGL renderer for GPU-accelerated terminal rendering.
|
||||
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
|
||||
// but the 48KB/frame flush cap in flushPendingWrites() now prevents
|
||||
// but the mode-aware 32/64KB frame cap in flushPendingWrites() now prevents
|
||||
// oversized terminal.write() calls that triggered the stalls.
|
||||
// Disable with ?nowebgl URL param if GPU issues return.
|
||||
// Auto-fallback: _initWebGL installs a long-task watchdog that disables
|
||||
@@ -2349,17 +2349,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Accumulate raw data (may contain DEC 2026 markers)
|
||||
this.pendingWrites.push(data);
|
||||
this._scheduleTerminalWriteFlush();
|
||||
},
|
||||
|
||||
if (!this.writeFrameScheduled) {
|
||||
/**
|
||||
* Schedule one render-budgeted terminal flush.
|
||||
*
|
||||
* Clear the scheduled flag before flushing so flushPendingWrites() can queue
|
||||
* another yield when a large final batch leaves bytes behind. Keeping the
|
||||
* flag set through the flush stranded that remainder until unrelated output
|
||||
* arrived, which looked like truncated responses and idle shell commands.
|
||||
*/
|
||||
_scheduleTerminalWriteFlush() {
|
||||
if (this.writeFrameScheduled || this.pendingWrites.length === 0) return;
|
||||
this.writeFrameScheduled = true;
|
||||
this._safeYield(() => {
|
||||
// xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers
|
||||
// content between 2026h/2026l and renders atomically. No need for
|
||||
// client-side incomplete-block detection; just flush every frame.
|
||||
this.flushPendingWrites();
|
||||
this.writeFrameScheduled = false;
|
||||
// xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers
|
||||
// content between 2026h/2026l and renders atomically.
|
||||
this.flushPendingWrites();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2375,13 +2384,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.flickerFilterActive = false;
|
||||
|
||||
// Trigger a normal flush
|
||||
if (!this.writeFrameScheduled) {
|
||||
this.writeFrameScheduled = true;
|
||||
this._safeYield(() => {
|
||||
this.flushPendingWrites();
|
||||
this.writeFrameScheduled = false;
|
||||
});
|
||||
}
|
||||
this._scheduleTerminalWriteFlush();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2530,13 +2533,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.write(joined.slice(0, MAX_FRAME_BYTES));
|
||||
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
|
||||
deferred = true;
|
||||
if (!this.writeFrameScheduled) {
|
||||
this.writeFrameScheduled = true;
|
||||
this._safeYield(() => {
|
||||
this.flushPendingWrites();
|
||||
this.writeFrameScheduled = false;
|
||||
});
|
||||
}
|
||||
this._scheduleTerminalWriteFlush();
|
||||
}
|
||||
if (
|
||||
preserveViewportY !== null &&
|
||||
|
||||
@@ -30,6 +30,7 @@ import { homedir, tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import type {
|
||||
InstallInfo,
|
||||
@@ -43,10 +44,9 @@ import type {
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version: APP_VERSION } = require('../../package.json') as { version: string };
|
||||
|
||||
/** systemd unit name (matches install.sh + scripts/codeman-web.service). */
|
||||
const SYSTEMD_UNIT = 'codeman-web.service';
|
||||
/** launchd agent label (matches install.sh setup_launchd_service). */
|
||||
const LAUNCHD_LABEL = 'com.codeman.web';
|
||||
// Unit name / job label live in config/service-names.ts so install.sh, this
|
||||
// detector and `codeman service install` cannot drift apart. Unchanged for the
|
||||
// default instance.
|
||||
/** Path to the persisted update status file. */
|
||||
const STATUS_FILE = dataPath('update-status.json');
|
||||
/** Network/git timeout for the "check" path (longer than EXEC_TIMEOUT_MS — ls-remote hits the network). */
|
||||
|
||||
@@ -71,7 +71,8 @@ describe('AiIdleChecker', () => {
|
||||
describe('Output Parsing', () => {
|
||||
it('should parse IDLE verdict', async () => {
|
||||
// Set up mock to return IDLE result after polling
|
||||
mockedReadFileSync.mockReturnValueOnce('') // writeFileSync creates empty file
|
||||
mockedReadFileSync
|
||||
.mockReturnValueOnce('') // writeFileSync creates empty file
|
||||
.mockReturnValueOnce('IDLE\nSession shows completion message and prompt.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('some terminal output');
|
||||
@@ -87,7 +88,8 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
|
||||
it('should parse WORKING verdict', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
mockedReadFileSync
|
||||
.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nSpinner characters detected, still processing.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('some terminal output');
|
||||
@@ -100,8 +102,7 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
|
||||
it('should handle lowercase verdict', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('idle\nDone.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('idle\nDone.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
@@ -112,7 +113,8 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
|
||||
it('should return ERROR for unparseable output', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
mockedReadFileSync
|
||||
.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('Something unexpected happened.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
@@ -125,8 +127,7 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
|
||||
it('should return ERROR for empty output', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
@@ -175,10 +176,34 @@ describe('AiIdleChecker', () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
expect(mockedWriteFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('codeman-aicheck-'),
|
||||
''
|
||||
expect(mockedWriteFileSync).toHaveBeenCalledWith(expect.stringContaining('codeman-aicheck-'), '');
|
||||
});
|
||||
|
||||
it('should keep Claude stderr separate from verdict output', async () => {
|
||||
mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
const spawnArgs = mockedSpawn.mock.calls[0]?.[1];
|
||||
const command = spawnArgs?.[spawnArgs.length - 1];
|
||||
expect(command).toEqual(expect.any(String));
|
||||
expect(command).toContain(' 2> "');
|
||||
expect(command).not.toContain('2>&1');
|
||||
});
|
||||
|
||||
it('should include Claude stderr when no verdict is produced', async () => {
|
||||
mockedReadFileSync.mockImplementation((path) =>
|
||||
String(path).includes('-stderr-') ? 'Claude CLI failed to load settings' : '__AICHECK_DONE__'
|
||||
);
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('ERROR');
|
||||
expect(result.reasoning).toContain('Claude CLI failed to load settings');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,7 +248,7 @@ describe('AiIdleChecker', () => {
|
||||
|
||||
// Should have tried to kill the tmux session (initial kill + cleanup kill)
|
||||
const killCalls = mockedExecSync.mock.calls.filter(
|
||||
call => typeof call[0] === 'string' && call[0].includes('kill-session')
|
||||
(call) => typeof call[0] === 'string' && call[0].includes('kill-session')
|
||||
);
|
||||
expect(killCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -236,8 +261,7 @@ describe('AiIdleChecker', () => {
|
||||
|
||||
describe('Cooldown', () => {
|
||||
it('should start cooldown after WORKING verdict', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nStill processing.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('WORKING\nStill processing.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
@@ -250,8 +274,7 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
|
||||
it('should return to ready after cooldown expires', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
@@ -267,8 +290,7 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
|
||||
it('should not start new check during cooldown', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const firstCheck = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
@@ -283,8 +305,7 @@ describe('AiIdleChecker', () => {
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should start error cooldown after parse error', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage output\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('garbage output\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
@@ -302,8 +323,7 @@ describe('AiIdleChecker', () => {
|
||||
const cooldowns = [1100, 2100]; // Wait slightly longer than each cooldown
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
@@ -321,8 +341,7 @@ describe('AiIdleChecker', () => {
|
||||
|
||||
it('should reset error counter on successful check', async () => {
|
||||
// First check: error
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
const firstCheck = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await firstCheck;
|
||||
@@ -332,8 +351,7 @@ describe('AiIdleChecker', () => {
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
|
||||
// Second check: success
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\nDone.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('IDLE\nDone.\n__AICHECK_DONE__');
|
||||
const secondCheck = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await secondCheck;
|
||||
@@ -352,8 +370,7 @@ describe('AiIdleChecker', () => {
|
||||
|
||||
describe('Buffer Handling', () => {
|
||||
it('should strip ANSI codes from terminal buffer', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const ansiBuffer = '\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m text';
|
||||
const checkPromise = checker.check(ansiBuffer);
|
||||
@@ -365,8 +382,7 @@ describe('AiIdleChecker', () => {
|
||||
});
|
||||
|
||||
it('should trim buffer to maxContextChars', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
// Create buffer longer than maxContextChars (1000)
|
||||
const longBuffer = 'x'.repeat(2000);
|
||||
@@ -402,8 +418,7 @@ describe('AiIdleChecker', () => {
|
||||
describe('Reset', () => {
|
||||
it('should clear all state on reset', async () => {
|
||||
// Trigger a WORKING verdict to set state
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
@@ -440,24 +455,24 @@ describe('AiIdleChecker', () => {
|
||||
const handler = vi.fn();
|
||||
checker.on('checkCompleted', handler);
|
||||
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\nAll done.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('IDLE\nAll done.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
verdict: 'IDLE',
|
||||
}));
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit cooldownStarted event after WORKING', async () => {
|
||||
const handler = vi.fn();
|
||||
checker.on('cooldownStarted', handler);
|
||||
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
@@ -477,8 +492,7 @@ describe('AiIdleChecker', () => {
|
||||
const cooldowns = [1100, 2100]; // Wait longer than exponential backoff
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
mockedReadFileSync.mockReturnValueOnce('').mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Unit tests for the pure halves of daemon-control (issue #231): argv rebuilding,
|
||||
* the readiness URL, pidfile parsing, the stale-pid identity check, and the
|
||||
* `/api/status` probe against a real socket.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import {
|
||||
buildBaseUrl,
|
||||
buildStatusUrl,
|
||||
buildWebArgs,
|
||||
isProcessAlive,
|
||||
looksLikeCodemanWeb,
|
||||
parsePidFileContents,
|
||||
probeServer,
|
||||
} from '../src/daemon-control.js';
|
||||
|
||||
const PORT = 3216;
|
||||
|
||||
describe('buildWebArgs', () => {
|
||||
it('always passes host and port through explicitly', () => {
|
||||
expect(buildWebArgs({ host: '127.0.0.1', port: 3000, https: false })).toEqual([
|
||||
'web',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'3000',
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards every optional flag it was given', () => {
|
||||
const args = buildWebArgs({
|
||||
host: '0.0.0.0',
|
||||
port: 8080,
|
||||
https: true,
|
||||
titleHostname: 'tower',
|
||||
allowUnauthenticatedNetwork: true,
|
||||
multiuser: true,
|
||||
});
|
||||
expect(args).toEqual([
|
||||
'web',
|
||||
'--host',
|
||||
'0.0.0.0',
|
||||
'--port',
|
||||
'8080',
|
||||
'--https',
|
||||
'--title-hostname',
|
||||
'tower',
|
||||
'--allow-unauthenticated-network',
|
||||
'--multiuser',
|
||||
]);
|
||||
});
|
||||
|
||||
it('never re-emits the daemon flags themselves (the child must not re-fork)', () => {
|
||||
const args = buildWebArgs({ host: '127.0.0.1', port: 3000, https: false });
|
||||
expect(args).not.toContain('--daemon');
|
||||
expect(args).not.toContain('-d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBaseUrl', () => {
|
||||
it('is the address a browser can open, with no path on it', () => {
|
||||
expect(buildBaseUrl({ host: '127.0.0.1', port: 3000, https: false })).toBe('http://127.0.0.1:3000');
|
||||
expect(buildBaseUrl({ host: '0.0.0.0', port: 8443, https: true })).toBe('https://127.0.0.1:8443');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildStatusUrl', () => {
|
||||
it('uses http by default and https when asked', () => {
|
||||
expect(buildStatusUrl({ host: '127.0.0.1', port: 3000, https: false })).toBe('http://127.0.0.1:3000/api/status');
|
||||
expect(buildStatusUrl({ host: '127.0.0.1', port: 3000, https: true })).toBe('https://127.0.0.1:3000/api/status');
|
||||
});
|
||||
|
||||
it('rewrites wildcard binds to loopback, since they are not connectable', () => {
|
||||
expect(buildStatusUrl({ host: '0.0.0.0', port: 3000, https: false })).toBe('http://127.0.0.1:3000/api/status');
|
||||
expect(buildStatusUrl({ host: '::', port: 3000, https: false })).toBe('http://127.0.0.1:3000/api/status');
|
||||
});
|
||||
|
||||
it('brackets a bare IPv6 literal', () => {
|
||||
expect(buildStatusUrl({ host: '::1', port: 3000, https: false })).toBe('http://[::1]:3000/api/status');
|
||||
expect(buildStatusUrl({ host: '[::1]', port: 3000, https: false })).toBe('http://[::1]:3000/api/status');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePidFileContents', () => {
|
||||
it('accepts a plain pid with surrounding whitespace', () => {
|
||||
expect(parsePidFileContents('4242\n')).toBe(4242);
|
||||
expect(parsePidFileContents(' 4242 ')).toBe(4242);
|
||||
});
|
||||
|
||||
it('rejects garbage, empties and floats', () => {
|
||||
expect(parsePidFileContents('')).toBeNull();
|
||||
expect(parsePidFileContents('not a pid')).toBeNull();
|
||||
expect(parsePidFileContents('42.5')).toBeNull();
|
||||
expect(parsePidFileContents('-42')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects pid 0 and pid 1: neither is ever our server', () => {
|
||||
expect(parsePidFileContents('0')).toBeNull();
|
||||
expect(parsePidFileContents('1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeCodemanWeb', () => {
|
||||
it('matches the ways the server is actually launched', () => {
|
||||
expect(looksLikeCodemanWeb('/usr/bin/node /home/u/.codeman/app/dist/index.js web')).toBe(true);
|
||||
expect(looksLikeCodemanWeb('/usr/bin/node dist/index.js web --https')).toBe(true);
|
||||
expect(looksLikeCodemanWeb('node /repo/src/index.ts web --port 3000')).toBe(true);
|
||||
expect(looksLikeCodemanWeb('/opt/homebrew/bin/codeman web')).toBe(true);
|
||||
expect(looksLikeCodemanWeb('aicodeman web --host 0.0.0.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects anything that inherited a recycled pid', () => {
|
||||
expect(looksLikeCodemanWeb(null)).toBe(false);
|
||||
expect(looksLikeCodemanWeb('')).toBe(false);
|
||||
expect(looksLikeCodemanWeb('/usr/bin/node dist/index.js session list')).toBe(false);
|
||||
expect(looksLikeCodemanWeb('vim web')).toBe(false);
|
||||
expect(looksLikeCodemanWeb('/usr/lib/systemd/systemd --user')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProcessAlive', () => {
|
||||
it('sees this very process', () => {
|
||||
expect(isProcessAlive(process.pid)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not see an unused high pid', () => {
|
||||
// 2^22 is above the default pid_max on Linux and macOS.
|
||||
expect(isProcessAlive(4_194_303)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeServer', () => {
|
||||
let server: http.Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
if (req.url === '/unauthorized') {
|
||||
res.writeHead(401).end('Unauthorized');
|
||||
return;
|
||||
}
|
||||
if (req.url === '/foreign') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' }).end('<html>some other app</html>');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, data: { version: '9.9.9' } }));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('reports up and reads the version back', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/api/status`);
|
||||
expect(result.up).toBe(true);
|
||||
expect(result.version).toBe('9.9.9');
|
||||
});
|
||||
|
||||
it('counts a 401 as up, because auth being active proves a server is there', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/unauthorized`);
|
||||
expect(result.up).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mistake an unrelated service squatting on the port for Codeman', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/foreign`);
|
||||
expect(result.up).toBe(false);
|
||||
});
|
||||
|
||||
it('reports down when nothing is listening', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT + 1}/api/status`, 1000);
|
||||
expect(result.up).toBe(false);
|
||||
});
|
||||
|
||||
it('reports down for a malformed url instead of throwing', async () => {
|
||||
const result = await probeServer('not-a-url');
|
||||
expect(result.up).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -127,7 +127,7 @@ describe('refreshStaleCodemanHooks', () => {
|
||||
|
||||
const after = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER);
|
||||
expect(JSON.stringify(after.hooks)).toContain('CODEMAN_BACKGROUND_REWAKE_V');
|
||||
expect(JSON.stringify(after.hooks)).toContain('CODEMAN_BACKGROUND_REWAKE_V3');
|
||||
expect(JSON.stringify(after.hooks.Stop)).toContain('./notify-user.sh');
|
||||
expect(after.hooks.PostToolUse).toEqual(expect.arrayContaining([customPostToolUse]));
|
||||
expect(after.hooks.CustomEvent).toEqual(customEvent);
|
||||
|
||||
+272
-3
@@ -6,13 +6,15 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
ensureCodemanHooks,
|
||||
generateBackgroundWakeScript,
|
||||
generateHooksConfig,
|
||||
generateSubagentStopGuardScript,
|
||||
refreshStaleCodemanHooks,
|
||||
writeHooksConfig,
|
||||
} from '../src/hooks-config.js';
|
||||
@@ -35,6 +37,20 @@ describe('generateHooksConfig', () => {
|
||||
expect(config.hooks.Stop).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should guard subagent stops while their background work is active', () => {
|
||||
const config = generateHooksConfig();
|
||||
const subagentHooks = config.hooks.SubagentStop as Array<{
|
||||
hooks: Array<{ type: string; command: string; args: string[]; timeout: number }>;
|
||||
}>;
|
||||
|
||||
expect(subagentHooks).toHaveLength(1);
|
||||
expect(subagentHooks[0].hooks[0]).toMatchObject({
|
||||
type: 'command',
|
||||
command: 'node',
|
||||
args: ['-e', generateSubagentStopGuardScript()],
|
||||
});
|
||||
});
|
||||
|
||||
it('should configure a self-contained Bash background-task rewake hook', () => {
|
||||
const config = generateHooksConfig();
|
||||
const postToolHooks = config.hooks.PostToolUse as Array<{
|
||||
@@ -210,7 +226,8 @@ describe('writeHooksConfig', () => {
|
||||
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(parsed.hooks.PostToolUse).toHaveLength(1);
|
||||
expect(JSON.stringify(parsed.hooks.PostToolUse)).toContain('CODEMAN_BACKGROUND_REWAKE_V');
|
||||
expect(JSON.stringify(parsed.hooks.PostToolUse)).toContain('CODEMAN_BACKGROUND_REWAKE_V3');
|
||||
expect(JSON.stringify(parsed.hooks.SubagentStop)).toContain('CODEMAN_SUBAGENT_STOP_GUARD_V1');
|
||||
});
|
||||
|
||||
it('should replace an older rewake script version without duplicating it', async () => {
|
||||
@@ -242,10 +259,29 @@ describe('writeHooksConfig', () => {
|
||||
const serialized = JSON.stringify(parsed.hooks.PostToolUse);
|
||||
expect(parsed.hooks.PostToolUse).toHaveLength(1);
|
||||
expect(parsed.hooks.PostToolUse[0].hooks).toHaveLength(1);
|
||||
expect(serialized).toContain('CODEMAN_BACKGROUND_REWAKE_V2');
|
||||
expect(serialized).toContain('CODEMAN_BACKGROUND_REWAKE_V3');
|
||||
expect(serialized).not.toContain('CODEMAN_BACKGROUND_REWAKE_V1');
|
||||
});
|
||||
|
||||
it('replaces the V2 background hook without duplicating it', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
const oldSettings = JSON.stringify({ hooks: generateHooksConfig().hooks }, null, 2).replaceAll(
|
||||
'CODEMAN_BACKGROUND_REWAKE_V3',
|
||||
'CODEMAN_BACKGROUND_REWAKE_V2'
|
||||
);
|
||||
writeFileSync(settingsPath, oldSettings);
|
||||
|
||||
await refreshStaleCodemanHooks(testDir);
|
||||
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
const postToolUse = JSON.stringify(parsed.hooks.PostToolUse);
|
||||
expect(parsed.hooks.PostToolUse).toHaveLength(1);
|
||||
expect(postToolUse).toContain('CODEMAN_BACKGROUND_REWAKE_V3');
|
||||
expect(postToolUse).not.toContain('CODEMAN_BACKGROUND_REWAKE_V2');
|
||||
});
|
||||
|
||||
it('should not add rewake hooks to a user-owned hook configuration', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
@@ -278,6 +314,35 @@ describe('writeHooksConfig', () => {
|
||||
expect(parsed.hooks.Notification).toBeDefined();
|
||||
});
|
||||
|
||||
it('should safely add Codeman hooks to an existing managed-case settings file', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
const userHooks = {
|
||||
PostToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: './format.sh' }] }],
|
||||
};
|
||||
writeFileSync(settingsPath, JSON.stringify({ hooks: userHooks, permissions: { allow: ['Read'] } }, null, 2));
|
||||
|
||||
await ensureCodemanHooks(testDir);
|
||||
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(parsed.permissions).toEqual({ allow: ['Read'] });
|
||||
expect(parsed.hooks.PostToolUse).toEqual(expect.arrayContaining(userHooks.PostToolUse));
|
||||
expect(JSON.stringify(parsed.hooks)).toContain('CODEMAN_BACKGROUND_REWAKE_V3');
|
||||
expect(JSON.stringify(parsed.hooks)).toContain('CODEMAN_SUBAGENT_STOP_GUARD_V1');
|
||||
});
|
||||
|
||||
it('should not replace a malformed managed-case settings file', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(settingsPath, '{ malformed');
|
||||
|
||||
await ensureCodemanHooks(testDir);
|
||||
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe('{ malformed');
|
||||
});
|
||||
|
||||
it('should handle malformed existing settings.local.json', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
@@ -370,6 +435,210 @@ describe('background task rewake helper', () => {
|
||||
expect(result.stderr).toContain('completed');
|
||||
expect(result.stderr).toContain('/tmp/bg-test-1.output');
|
||||
});
|
||||
|
||||
it('rewakes a subagent when Claude queues completion in the parent transcript', async () => {
|
||||
const sessionId = '7148e9de-7673-48b8-bf38-6799e52c346a';
|
||||
const sessionDir = join(testDir, sessionId);
|
||||
const subagentDir = join(sessionDir, 'subagents');
|
||||
const parentTranscriptPath = `${sessionDir}.jsonl`;
|
||||
const subagentTranscriptPath = join(subagentDir, 'agent-afacts-class2.jsonl');
|
||||
mkdirSync(subagentDir, { recursive: true });
|
||||
writeFileSync(parentTranscriptPath, '');
|
||||
writeFileSync(subagentTranscriptPath, '');
|
||||
|
||||
const resultPromise = runHelper({
|
||||
session_id: sessionId,
|
||||
agent_id: 'afacts-class2',
|
||||
transcript_path: subagentTranscriptPath,
|
||||
tool_response: {
|
||||
backgroundTaskId: 'bg-subagent-1',
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
writeFileSync(
|
||||
parentTranscriptPath,
|
||||
JSON.stringify({
|
||||
type: 'queue-operation',
|
||||
operation: 'enqueue',
|
||||
content:
|
||||
'<task-notification>\n<task-id>bg-subagent-1</task-id>\n<status>completed</status>\n' +
|
||||
'<output-file>/tmp/bg-subagent-1.output</output-file>\n</task-notification>',
|
||||
}) + '\n'
|
||||
);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.code).toBe(2);
|
||||
expect(result.stderr).toContain('bg-subagent-1');
|
||||
expect(result.stderr).toContain('/tmp/bg-subagent-1.output');
|
||||
});
|
||||
|
||||
it('includes a marked background report in the wake feedback', async () => {
|
||||
const transcriptPath = join(testDir, 'transcript.jsonl');
|
||||
const tasksDir = join(testDir, 'tasks');
|
||||
const outputPath = join(tasksDir, 'bg-report-1.output');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
writeFileSync(transcriptPath, '');
|
||||
writeFileSync(
|
||||
outputPath,
|
||||
[
|
||||
'launcher output',
|
||||
'=== CODEMAN_RESULT_BEGIN ===',
|
||||
'Summary line',
|
||||
'Detail after the old 30-line preview boundary',
|
||||
'=== CODEMAN_RESULT_END ===',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const resultPromise = runHelper({
|
||||
transcript_path: transcriptPath,
|
||||
tool_response: {
|
||||
stdout: `Command running in background with ID: bg-report-1. Output is being written to: ${outputPath}.`,
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
writeFileSync(
|
||||
transcriptPath,
|
||||
JSON.stringify({
|
||||
type: 'queue-operation',
|
||||
operation: 'enqueue',
|
||||
content:
|
||||
'<task-notification>\n<task-id>bg-report-1</task-id>\n<status>completed</status>\n' +
|
||||
`<output-file>${outputPath}</output-file>\n</task-notification>`,
|
||||
}) + '\n'
|
||||
);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.code).toBe(2);
|
||||
expect(result.stderr).toContain('<codeman-background-result>');
|
||||
expect(result.stderr).toContain('Summary line');
|
||||
expect(result.stderr).toContain('Detail after the old 30-line preview boundary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subagent stop guard helper', () => {
|
||||
const testDir = join(tmpdir(), 'codeman-subagent-stop-guard-test-' + Date.now());
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function runGuard(transcriptLines: unknown[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
const transcriptPath = join(testDir, 'agent-test.jsonl');
|
||||
writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['-e', generateSubagentStopGuardScript()], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
||||
child.stdin.end(JSON.stringify({ agent_transcript_path: transcriptPath }));
|
||||
});
|
||||
}
|
||||
|
||||
async function withLiveTask<T>(taskId: string, action: () => Promise<T>): Promise<T> {
|
||||
const tasksDir = join(testDir, 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
const outputFd = openSync(join(tasksDir, `${taskId}.output`), 'a');
|
||||
const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 10000)'], {
|
||||
stdio: ['ignore', outputFd, outputFd],
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once('spawn', resolve);
|
||||
child.once('error', reject);
|
||||
});
|
||||
closeSync(outputFd);
|
||||
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
const closed = new Promise<void>((resolve) => child.once('close', () => resolve()));
|
||||
child.kill();
|
||||
await closed;
|
||||
}
|
||||
}
|
||||
|
||||
const monitorResult = (taskId: string) => ({
|
||||
type: 'user',
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
content: `Monitor started (task ${taskId}, pid 123).`,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const completion = (taskId: string) => ({
|
||||
type: 'user',
|
||||
message: {
|
||||
content:
|
||||
`<task-notification>\n<task-id>${taskId}</task-id>\n` + '<status>completed</status>\n</task-notification>',
|
||||
},
|
||||
});
|
||||
|
||||
it('blocks an intermediate subagent stop while a sibling monitor is active', async () => {
|
||||
const result = await withLiveTask('monitor-still-live', () =>
|
||||
runGuard([monitorResult('monitor-first'), monitorResult('monitor-still-live'), completion('monitor-first')])
|
||||
);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({ decision: 'block' });
|
||||
expect(result.stdout).toContain('monitor-still-live');
|
||||
expect(result.stdout).not.toContain('monitor-first,');
|
||||
});
|
||||
|
||||
it('allows a subagent to stop after all of its monitored work finishes', async () => {
|
||||
const result = await runGuard([
|
||||
monitorResult('monitor-first'),
|
||||
monitorResult('monitor-second'),
|
||||
completion('monitor-first'),
|
||||
completion('monitor-second'),
|
||||
]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toBe('');
|
||||
});
|
||||
|
||||
it('also recognizes background Bash task ownership', async () => {
|
||||
const result = await withLiveTask('bash-live-1', () =>
|
||||
runGuard([
|
||||
{
|
||||
type: 'user',
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
content: 'Command running in background with ID: bash-live-1. Output is being written to a task file.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({ decision: 'block' });
|
||||
expect(result.stdout).toContain('bash-live-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Hook Event API Integration Tests ==========
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Unit tests for the unit-file builders behind `codeman service install`
|
||||
* (issue #231). These are the parts that must be right without launchctl or
|
||||
* systemctl in the loop: PATH construction, escaping, and the file contents.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildLaunchAgentPlist,
|
||||
buildServiceEnv,
|
||||
buildServicePath,
|
||||
buildSystemdUnit,
|
||||
detectServiceKind,
|
||||
systemdQuote,
|
||||
xmlEscape,
|
||||
type ServicePlan,
|
||||
} from '../src/service-installer.js';
|
||||
|
||||
function plan(overrides: Partial<ServicePlan> = {}): ServicePlan {
|
||||
return {
|
||||
kind: 'systemd',
|
||||
name: 'codeman-web.service',
|
||||
nodePath: '/usr/bin/node',
|
||||
execArgv: [],
|
||||
scriptPath: '/home/u/.codeman/app/dist/index.js',
|
||||
args: ['web', '--host', '127.0.0.1', '--port', '3000'],
|
||||
env: { PATH: '/usr/bin:/bin', HOME: '/home/u', LANG: 'en_US.UTF-8' },
|
||||
logPath: '/home/u/.codeman/web.log',
|
||||
workingDir: '/home/u',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildServicePath', () => {
|
||||
it("puts the running node's directory first so nvm/homebrew node wins", () => {
|
||||
const result = buildServicePath('/home/u/.nvm/versions/node/v22.0.0/bin', '/usr/bin:/bin', '/home/u');
|
||||
expect(result.split(':')[0]).toBe('/home/u/.nvm/versions/node/v22.0.0/bin');
|
||||
});
|
||||
|
||||
it('keeps the installing shell PATH, which is the whole point of the fix', () => {
|
||||
const result = buildServicePath('/usr/bin', '/opt/homebrew/bin:/home/u/.bun/bin', '/home/u');
|
||||
expect(result.split(':')).toContain('/home/u/.bun/bin');
|
||||
expect(result.split(':')).toContain('/opt/homebrew/bin');
|
||||
});
|
||||
|
||||
it('appends the fallbacks a bare launchd PATH would otherwise be missing', () => {
|
||||
const entries = buildServicePath('/usr/bin', '/usr/bin', '/home/u').split(':');
|
||||
expect(entries).toContain('/opt/homebrew/bin');
|
||||
expect(entries).toContain('/home/u/.local/bin');
|
||||
expect(entries).toContain('/usr/local/bin');
|
||||
});
|
||||
|
||||
it('never repeats a directory', () => {
|
||||
const entries = buildServicePath('/usr/bin', '/usr/bin:/bin:/usr/bin', '/home/u').split(':');
|
||||
expect(new Set(entries).size).toBe(entries.length);
|
||||
});
|
||||
|
||||
it('drops empty segments from a trailing-colon PATH', () => {
|
||||
expect(buildServicePath('/usr/bin', '/usr/bin::/bin:', '/home/u').split(':')).not.toContain('');
|
||||
});
|
||||
|
||||
it('drops node_modules/.bin, which npx injects for one command only', () => {
|
||||
const entries = buildServicePath(
|
||||
'/usr/bin',
|
||||
'/repo/node_modules/.bin:/repo/node_modules/.bin/:/home/u/bin',
|
||||
'/home/u'
|
||||
).split(':');
|
||||
expect(entries.filter((e) => e.includes('node_modules'))).toEqual([]);
|
||||
expect(entries).toContain('/home/u/bin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildServiceEnv', () => {
|
||||
it('carries PATH, HOME and a LANG default', () => {
|
||||
const env = buildServiceEnv('/usr/bin', '/usr/bin:/bin', '/home/u');
|
||||
expect(env.HOME).toBe('/home/u');
|
||||
expect(env.LANG).toBe('en_US.UTF-8');
|
||||
expect(env.PATH).toContain('/usr/bin');
|
||||
});
|
||||
|
||||
it('prefers the caller LANG when there is one', () => {
|
||||
expect(buildServiceEnv('/usr/bin', '/usr/bin', '/home/u', 'de_DE.UTF-8').LANG).toBe('de_DE.UTF-8');
|
||||
});
|
||||
|
||||
it('does not carry a password into the unit file', () => {
|
||||
const env = buildServiceEnv('/usr/bin', '/usr/bin', '/home/u');
|
||||
expect(Object.keys(env)).not.toContain('CODEMAN_PASSWORD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escaping', () => {
|
||||
it('escapes the five XML entities', () => {
|
||||
expect(xmlEscape(`a&b<c>d"e'f`)).toBe('a&b<c>d"e'f');
|
||||
});
|
||||
|
||||
it('quotes systemd values and escapes quotes and backslashes', () => {
|
||||
expect(systemdQuote('plain')).toBe('"plain"');
|
||||
expect(systemdQuote('with "quotes"')).toBe('"with \\"quotes\\""');
|
||||
expect(systemdQuote('back\\slash')).toBe('"back\\\\slash"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLaunchAgentPlist', () => {
|
||||
it('writes the label, the full command and the log paths', () => {
|
||||
const xml = buildLaunchAgentPlist(plan({ kind: 'launchd', name: 'com.codeman.web' }));
|
||||
expect(xml).toContain('<string>com.codeman.web</string>');
|
||||
expect(xml).toContain('<string>/usr/bin/node</string>');
|
||||
expect(xml).toContain('<string>/home/u/.codeman/app/dist/index.js</string>');
|
||||
expect(xml).toContain('<string>web</string>');
|
||||
expect(xml).toContain('<string>/home/u/.codeman/web.log</string>');
|
||||
});
|
||||
|
||||
it('keeps the argument order: node, script, then the web args', () => {
|
||||
const xml = buildLaunchAgentPlist(plan({ kind: 'launchd', name: 'com.codeman.web' }));
|
||||
// Match whole <string> elements: the label itself contains the word "web".
|
||||
const order = [
|
||||
'<string>/usr/bin/node</string>',
|
||||
'<string>/home/u/.codeman/app/dist/index.js</string>',
|
||||
'<string>web</string>',
|
||||
'<string>--port</string>',
|
||||
].map((s) => xml.indexOf(s));
|
||||
expect(order).toEqual([...order].sort((a, b) => a - b));
|
||||
expect(order.every((i) => i > -1)).toBe(true);
|
||||
});
|
||||
|
||||
it('carries the runner flags so a tsx dev install still boots', () => {
|
||||
const xml = buildLaunchAgentPlist(plan({ kind: 'launchd', execArgv: ['--import', 'tsx'] }));
|
||||
expect(xml).toContain('<string>--import</string>');
|
||||
expect(xml).toContain('<string>tsx</string>');
|
||||
});
|
||||
|
||||
it('restarts on crash and at login', () => {
|
||||
const xml = buildLaunchAgentPlist(plan({ kind: 'launchd' }));
|
||||
expect(xml).toContain('<key>KeepAlive</key>');
|
||||
expect(xml).toContain('<key>RunAtLoad</key>');
|
||||
});
|
||||
|
||||
it('escapes a path with an ampersand instead of emitting broken XML', () => {
|
||||
const xml = buildLaunchAgentPlist(plan({ kind: 'launchd', workingDir: '/Users/a&b' }));
|
||||
expect(xml).toContain('<string>/Users/a&b</string>');
|
||||
expect(xml).not.toContain('<string>/Users/a&b</string>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemdUnit', () => {
|
||||
it('builds ExecStart from node, script and args', () => {
|
||||
expect(buildSystemdUnit(plan())).toContain(
|
||||
'ExecStart=/usr/bin/node /home/u/.codeman/app/dist/index.js web --host 127.0.0.1 --port 3000'
|
||||
);
|
||||
});
|
||||
|
||||
it('quotes an argument containing spaces', () => {
|
||||
const unit = buildSystemdUnit(plan({ scriptPath: '/home/my user/app/dist/index.js' }));
|
||||
expect(unit).toContain('"/home/my user/app/dist/index.js"');
|
||||
});
|
||||
|
||||
it('writes each env var as a quoted Environment line', () => {
|
||||
const unit = buildSystemdUnit(plan());
|
||||
expect(unit).toContain('Environment="PATH=/usr/bin:/bin"');
|
||||
expect(unit).toContain('Environment="HOME=/home/u"');
|
||||
});
|
||||
|
||||
it('keeps KillMode=process so agents survive a server restart', () => {
|
||||
expect(buildSystemdUnit(plan())).toContain('KillMode=process');
|
||||
});
|
||||
|
||||
it('is installable and restarts on failure', () => {
|
||||
const unit = buildSystemdUnit(plan());
|
||||
expect(unit).toContain('Restart=always');
|
||||
expect(unit).toContain('WantedBy=default.target');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectServiceKind', () => {
|
||||
it('maps the platform to its supervisor', () => {
|
||||
const expected = process.platform === 'darwin' ? 'launchd' : process.platform === 'linux' ? 'systemd' : null;
|
||||
expect(detectServiceKind()).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,26 @@ function loadTerminalUiHarness(mode: string) {
|
||||
}
|
||||
|
||||
describe('terminal flush budget', () => {
|
||||
it('drains a large final batch without waiting for unrelated terminal output', () => {
|
||||
const { app, writes } = loadTerminalUiHarness('codex');
|
||||
const scheduled: Array<() => void> = [];
|
||||
app._safeYield = (callback: () => void) => {
|
||||
scheduled.push(callback);
|
||||
};
|
||||
app.isTerminalAtBottom = () => true;
|
||||
|
||||
app.batchTerminalWrite('x'.repeat(96 * 1024));
|
||||
expect(scheduled).toHaveLength(1);
|
||||
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024, 32 * 1024]);
|
||||
expect(app.pendingWrites).toEqual([]);
|
||||
expect(app.writeFrameScheduled).toBe(false);
|
||||
});
|
||||
|
||||
it('uses a smaller first-frame write budget for Codex output to reduce renderer stalls', () => {
|
||||
const { app, writes } = loadTerminalUiHarness('codex');
|
||||
app.pendingWrites.push('x'.repeat(96 * 1024));
|
||||
|
||||
Reference in New Issue
Block a user