mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-14 11:19:42 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
233f85b170 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'aicodeman': minor
|
||||
---
|
||||
|
||||
Keep your laptop awake while Codeman runs. App Settings > System > Power has "Keep this computer awake" (off by default) and "Only on AC power" (on by default), with a live status line. On Linux it stops lid-close suspend while you are logged in to the desktop; on macOS it stops idle sleep, and lid-close sleep too with the optional root helper. The installer asks on laptops, and `install.sh keep-awake` turns it on for an existing install.
|
||||
@@ -198,9 +198,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
|
||||
|
||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||
|
||||
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
|
||||
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents in modes that never get the claude-only preamble (`seedAgentSessionPreamble` is local-claude only; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
|
||||
|
||||
**Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker)
|
||||
|
||||
@@ -293,6 +293,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||
|
||||
**Keep awake** (`keepAwakeEnabled`, SYNCED, default OFF; `keepAwakeAcOnly` default ON; pure `src/keep-awake.ts` + IO `src/keep-awake-manager.ts`, status `GET /api/system/keep-awake`): holds an OS sleep lock while the server runs, so a closed laptop keeps its agents working. ⚠️ Linux needs the LOW-level `handle-lid-switch` lock (logind's default `LidSwitchIgnoreInhibited=yes` ignores a `sleep` lock for the lid, and a user's own `sleep` lock never blocks that user's desktop suspending), and polkit grants it only while someone is logged in to the desktop: `denied` is a retried state, never an error. ⚠️ The lock must die with the server: `systemd-inhibit` runs `cat` on a stdin pipe (the unit's `KillMode=process` would orphan a `sleep infinity` holding it forever), macOS runs `caffeinate -w <pid>`. ⚠️ macOS lid close needs `pmset -a disablesleep`, applied by the optional root helper `scripts/keep-awake-macos.sh`, which `install.sh` copies to a ROOT-OWNED path (never run it from the user-writable install dir); it follows a heartbeat request file it ignores after 2 minutes, and undoes only a disablesleep it set. Reconcile from `merged` in `PUT /api/settings`; a non-admin's value is dropped in multi-user mode. Tests: `test/keep-awake*.test.ts`, `test/routes/system-routes-keep-awake.test.ts`, `test/install-sh-keep-awake.test.ts`.
|
||||
|
||||
**Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; pure single source `src/config/base-path.ts`, normalized to `''` or `/foo`): mounts Codeman under a sub-path behind a proxy that forwards the prefix unchanged. Few choke points: `stripBasePath()` in Fastify's `rewriteUrl` (routes stay prefix-agnostic; unprefixed requests still answer), one `onSend` hook rebasing `Location`, `renderIndexHtml` rewriting `<base href>` + injecting `window.__CODEMAN_BASE__`, and `CodemanBase.url()` (constants.js) for runtime URLs. ⚠️ Keep template asset refs RELATIVE, and route every root-absolute frontend URL (EventSource/WebSocket/`window.open`/src) through `CodemanBase.url()`. ⚠️ Web-tab proxy egress goes through `proxyPrefixFor(cap, basePath)`; ingress parsers stay base-agnostic. ⚠️ `--base-url` must ride `buildWebArgs` and `resolveServicePlan`. Tests: `test/base-path.test.ts`. → [architecture-invariants#reverse-proxy-base-path](docs/architecture-invariants.md#reverse-proxy-base-path)
|
||||
|
||||
**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)
|
||||
|
||||
@@ -733,23 +733,23 @@ For AI agents and automation that control Codeman without a browser: an agent th
|
||||
|
||||
### The agent skill (start here)
|
||||
|
||||
Everything in this section also ships as an **agent skill**, [`skills/codeman`](skills/codeman/SKILL.md), in the `SKILL.md` format Claude Code, Codex, Gemini CLI, OpenCode, Grok, OMP and most other agent CLIs read. Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
|
||||
Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/images/codeman-skill-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="A real codeman skill run: one short prompt typed into Claude Code, the tile grid powering on, then a DeepSeek Harness worker on a local qwen model and a Claude Code worker powering on as new tiles, with lineage lines from the lead to both" width="900"></a>
|
||||
<a href="docs/images/codeman-skill-20261010.png"><img src="docs/images/codeman-skill-20261010.gif" alt="A real codeman skill run: one plain-English request to a lead session, three Claude Code workers opening as new tabs, and lineage lines from the lead to every worker" width="900"></a>
|
||||
</p>
|
||||
|
||||
#### Step 1: install it
|
||||
|
||||
| How | Command | Scope |
|
||||
| -------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, for any skills-aware agent ([skills.sh](https://skills.sh)); the way to give it to pi |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, works for any skills-aware agent |
|
||||
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman` then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager; `/plugin update codeman` follows releases. Pick this OR a `codeman skill install`, not both: a Claude Code with both lists the skill twice (`codeman` and `codeman:codeman`) |
|
||||
| Bundled CLI | `codeman skill install` | Global (`~/.claude/skills/codeman`), for npm installs that never cloned the repo |
|
||||
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case only; `--mode codex` (or any other run mode) writes it where that CLI looks for skills |
|
||||
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | Auto-injects into each case on session create, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; not pi, whose project skills trigger a trust prompt (`agentSkillEnabled`, SYNCED, default off) |
|
||||
| Bundled CLI | `codeman skill install --case <name>` | One case only |
|
||||
| Web UI | App Settings → Agents & CLIs → Claude → **Agent Skill** | Auto-injects into each case on Claude session create (`agentSkillEnabled`, SYNCED, default off) |
|
||||
|
||||
`codeman skill uninstall [--case <name>]` reverses the CLI installs (with `--case`, every Codeman-managed copy in that case, whichever CLI's directory it sits in), and never touches a `skills/codeman` you wrote yourself.
|
||||
`codeman skill uninstall [--case <name>]` reverses the CLI installs, and never touches a `skills/codeman` you wrote yourself.
|
||||
|
||||
#### Step 2: ask for things
|
||||
|
||||
@@ -802,7 +802,7 @@ Every recipe in there was verified against a live server, and the comments recor
|
||||
- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) the skill refuses to act and does not guess an API URL, so a global install costs an unrelated Claude Code session nothing.
|
||||
- **It is deliberately conservative.** Unprompted, it may only spawn sessions, prompt them, and delete ones **it created in that same conversation, by exact id**, through a fail-closed guard that refuses to delete the agent's own session. Deleting a case (which erases a real directory of your code), bulk kills, respawn/ralph/cron/orchestrator changes and settings writes all require you to ask, naming the target.
|
||||
|
||||
⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that `.claude/` dir). Remove them per case with `codeman skill uninstall --case <name>`.
|
||||
|
||||
---
|
||||
|
||||
@@ -946,7 +946,7 @@ codeman tui 3 # attach to session 3 of that list
|
||||
|
||||
### `codeman agent` — session-to-session verbs in every CLI mode
|
||||
|
||||
The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
|
||||
The skill above is claude-shaped (Codeman seeds its preamble for claude sessions only). An `opencode`, `codex`, `pi` or `gemini` agent has the same environment (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane) but nothing that teaches it the verbs — so `codeman agent` packages them as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
|
||||
|
||||
```bash
|
||||
codeman agent ls # sessions; * marks this one
|
||||
|
||||
+5
-88
@@ -87,93 +87,6 @@ the HTTP status.
|
||||
|
||||
Adding a new error code is non-breaking; removing or renaming one is a major change.
|
||||
|
||||
## Cron jobs
|
||||
|
||||
Saved jobs and their launch history are separate from the legacy `/api/scheduled`
|
||||
duration-bounded loops. Use `/api/v1/cron/...` in external clients; `/api/cron/...`
|
||||
is the unversioned alias. These routes use the response envelope above; the table
|
||||
lists the value inside `data` on success.
|
||||
|
||||
| Method | Path | Request body | Response `data` |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/cron/jobs` | None | `CronJob[]` |
|
||||
| POST | `/api/v1/cron/jobs` | Full job definition below | `{ job: CronJob }` |
|
||||
| GET | `/api/v1/cron/jobs/:id` | None | `CronJob` |
|
||||
| PUT | `/api/v1/cron/jobs/:id` | Partial job definition | `{ job: CronJob }` |
|
||||
| DELETE | `/api/v1/cron/jobs/:id` | None | `{}` |
|
||||
| PUT | `/api/v1/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job: CronJob }` |
|
||||
| POST | `/api/v1/cron/jobs/:id/run` | None | `{ run: CronJobRun, activeAgents: number }` |
|
||||
| GET | `/api/v1/cron/jobs/:id/runs` | None | `CronJobRun[]` |
|
||||
| GET | `/api/v1/cron/runs` | None | `CronJobRun[]` |
|
||||
|
||||
### Job request fields
|
||||
|
||||
The create body requires `name`, `agentType`, `workingDir`, `promptMode`,
|
||||
`inputMode`, `scheduleType`, `enabled`, and `concurrencyPolicy`. Additional fields
|
||||
are required according to the selected prompt and schedule:
|
||||
|
||||
| Field | Type / validation |
|
||||
| --- | --- |
|
||||
| `name` | String, 1–200 characters |
|
||||
| `agentType` | A supported session mode (including `shell`) |
|
||||
| `workingDir` | Existing, allowed working-directory path |
|
||||
| `launchCommand` | Optional single-line string, at most 2000 characters; for shell jobs |
|
||||
| `promptMode` | `inline_text` or `prompt_file_path` |
|
||||
| `promptText` | Required for `inline_text`; nonempty single-line string, at most 100000 characters |
|
||||
| `promptFilePath` | Required for `prompt_file_path`; absolute path inside `workingDir` to a regular file, at most 1 MiB, read when the job fires |
|
||||
| `inputMode` | `paste` or `typed` |
|
||||
| `scheduleType` | `once`, `interval`, `daily`, or `weekly` |
|
||||
| `runAt` | Required for `once`; positive integer Unix timestamp in milliseconds |
|
||||
| `intervalMinutes` | Required for `interval`; integer from 1 to 525600 |
|
||||
| `dailyTime` | Required for `daily`; `HH:MM` in server-local time |
|
||||
| `weeklyDays` | Required for `weekly`; 1–7 weekday integers, 0 (Sunday) through 6 (Saturday) |
|
||||
| `weeklyTime` | Required for `weekly`; `HH:MM` in server-local time |
|
||||
| `enabled` | Boolean |
|
||||
| `concurrencyPolicy` | `warn_only` or `skip_if_same_agent_running`; scheduled runs only |
|
||||
| `autoClosePreviousSession` | Optional boolean, default `true`; ignored for `once` |
|
||||
| `notes` | Optional string, at most 2000 characters |
|
||||
|
||||
`PUT /jobs/:id` accepts any subset of these fields, then validates the merged job.
|
||||
When changing `promptMode` or `scheduleType`, supply the fields the new mode needs.
|
||||
`Run Now` works even when the job is disabled, bypasses the scheduled concurrency
|
||||
policy, and does not change the schedule. `activeAgents` counts live sessions of
|
||||
the same agent type, excluding sessions created by this job.
|
||||
For recurring jobs with `autoClosePreviousSession` enabled (the default), `Run Now`
|
||||
also closes the previous run's session before launching, even if it is still working.
|
||||
|
||||
### Job and run response fields
|
||||
|
||||
`CronJob` contains the request fields plus server-maintained `id`, optional
|
||||
`owner` (multi-user mode), `createdAt`, `updatedAt`, `lastRunAt`, `nextRunAt`,
|
||||
`lastStatus`, `lastDueKey`, and optional `completedOnce`. Times are Unix
|
||||
milliseconds; `lastRunAt`, `nextRunAt`, `lastStatus`, and `lastDueKey` can be `null`.
|
||||
`lastDueKey` is an opaque internal duplicate-launch guard, not a stable API format.
|
||||
|
||||
`CronJobRun` contains `id`, `cronJobId`, nullable `sessionId` and `sessionName`,
|
||||
`startedAt`, nullable `finishedAt`, `status`, optional `errorMessage`,
|
||||
`triggerType` (`scheduled` or `manual_run_now`), and nullable `createdSessionUrl`.
|
||||
Run times are also Unix milliseconds. Status is one of `created`,
|
||||
`session_started`, `prompt_sent`, `failed`, or `skipped`.
|
||||
|
||||
Prompt delivery continues asynchronously after session launch, so `Run Now` can
|
||||
return `session_started` before the prompt is sent. Read run history for subsequent
|
||||
updates, but do not assume a terminal status will follow: if the session is closed
|
||||
during the readiness wait or the server restarts before delivery, the run can remain
|
||||
`session_started` indefinitely with `finishedAt: null`.
|
||||
`finishedAt` refers to the launch/prompt-delivery attempt, **not completion
|
||||
of the agent's task**; `prompt_sent` does not prove that the task succeeded.
|
||||
|
||||
In multi-user mode, list/history endpoints filter to accessible jobs. An unknown
|
||||
or inaccessible job returns `NOT_FOUND`. Job creation and updates can return
|
||||
`403 FORBIDDEN` for a working directory outside the owner's workspace or a shell /
|
||||
launch-command job without the required privilege grant. Invalid definitions or
|
||||
working directories return `INVALID_INPUT`; launch/delivery failures are recorded
|
||||
on the run, so inspect its `status` and `errorMessage` even after an HTTP success.
|
||||
|
||||
See [Cron Jobs](wiki/Cron-Jobs.md) for the UI, scheduling, and prompt-file rules.
|
||||
See the [complete cron guide](cron-guide.md) for the `cron:runCreated` and
|
||||
`cron:runUpdated` SSE events.
|
||||
|
||||
## Long-polling (agent wait)
|
||||
|
||||
Three calls block until something happens instead of answering immediately. They
|
||||
@@ -555,7 +468,7 @@ geometry was read. The capture runs synchronous tmux calls on the server; the
|
||||
|
||||
## The `codeman agent` CLI (client over these endpoints)
|
||||
|
||||
`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
|
||||
`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents in modes that never receive the claude-only skill preamble. It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
|
||||
|
||||
## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`)
|
||||
|
||||
@@ -947,6 +860,10 @@ Delivery goes through the same egress guard as web tabs (refused on the resolved
|
||||
|
||||
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
|
||||
|
||||
## Keep awake
|
||||
|
||||
`GET /api/system/keep-awake` reports the sleep lock behind the `keepAwakeEnabled` setting (switched through `PUT /api/settings`, together with `keepAwakeAcOnly`): `{ enabled, acOnly, platform: linux|macos|unsupported, state, onAc, lidHelper, detail }`. `state` is `off` \| `paused-battery` \| `starting` \| `active` \| `denied` (Linux refused the lock because no one is logged in to a desktop; retried every minute) \| `unavailable` \| `failed` (retried). `onAc` is `null` when unknown or not read; `lidHelper` is `installed` \| `missing` on macOS and `null` elsewhere. In multi-user mode a non-admin's `keepAwake*` values in `PUT /api/settings` are dropped (the rest of the save goes through).
|
||||
|
||||
## Voice dictation
|
||||
|
||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||
|
||||
@@ -143,7 +143,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.
|
||||
|
||||
Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant.
|
||||
|
||||
**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case <name>]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default OFF). ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route.
|
||||
**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case <name>]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route.
|
||||
|
||||
Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused).
|
||||
|
||||
|
||||
@@ -128,18 +128,6 @@ sure its row is one the agent cannot write.
|
||||
|
||||
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
|
||||
|
||||
### Where the agent skill lands
|
||||
|
||||
`agentSkillInjection` says a CLI's sessions get the agent skill (`skills/codeman`) and its per-session preamble when the synced `agentSkillEnabled` setting is on; `agentSkillDir` says which workspace directory the CLI scans for skills, read through `agentSkillDir()` (hooks-config.ts), so no call site branches on a CLI id. Absent with the boolean on means `.claude/skills`, which is what the boolean meant before the field existed.
|
||||
|
||||
| CLI | Skill directory |
|
||||
| --- | --- |
|
||||
| claude | `.claude/skills/codeman` |
|
||||
| codex, gemini, opencode, antigravity, grok, deepseek, omp | `.agents/skills/codeman` |
|
||||
| pi, shell | none |
|
||||
|
||||
Measured 2026-10-11 against the installed builds or their source: every CLI in the middle row scans `<workspace>/.agents/skills/` (gemini only in its cwd, dsh only at the git root, else cwd), and Claude Code does not read it, so a case shared with a claude session lists the skill once there (opencode, grok and omp read both directories and see the same skill twice, identical, under one name). ⚠️ pi is left out on purpose: any project-level `.agents/skills` or `.pi/skills` makes pi ask "Trust project folder?" at startup, which would stall a spawned worker on a dialog; it reads the user-level `~/.agents/skills` without the prompt (`npx skills add Ark0N/Codeman --skill codeman -g`). A codex, gemini or other session's copy in a case that pi sessions also use triggers that prompt once for the human there. ⚠️ `agentSkillDir` is a closed enum (`AGENT_SKILL_DIRS`, schema.ts), never a free path: `clis.json` can set it and it decides where the server writes. ⚠️ Two codex traps the skill names rather than fixes: codex's default sandbox blocks network even to loopback (the skill needs **Bypass approvals and sandbox**), and codex 0.162 runs tool commands through a shared `app-server` daemon whose environment (`CODEMAN_SESSION_ID`, `CODEMAN_API_URL`) is that of the session that started it.
|
||||
|
||||
## The newline chord
|
||||
|
||||
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ spin up a Claude (or shell / OpenCode / Codex / Antigravity / Gemini / Pi) sessi
|
||||
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
|
||||
Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
||||
|
||||
- **UI**: the **⏰ Cron** button in the bottom toolbar → the Cron Jobs modal (`#cronModal`).
|
||||
- **UI**: the **⏰ Cron** button in the header → the Cron Jobs modal (`#cronModal`).
|
||||
- **API**: `/api/cron/jobs*` and `/api/cron/runs`.
|
||||
- **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`,
|
||||
types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`,
|
||||
@@ -24,7 +24,7 @@ Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
||||
|
||||
### In the browser
|
||||
|
||||
1. Click **⏰ Cron** in the bottom toolbar.
|
||||
1. Click **⏰ Cron** in the header.
|
||||
2. Click **+ New Job**.
|
||||
3. Fill in a **name**, pick an **agent type** and **working directory**, choose a
|
||||
**prompt** (inline text or a file path), pick a **schedule**, and leave
|
||||
|
||||
@@ -163,10 +163,9 @@ respawn, Ralph, the orchestrator, search, and admin. Each route module carries a
|
||||
If the caller is an agent running _inside_ a Codeman session, install the packaged
|
||||
agent skill instead of teaching it these calls by hand: `skills/codeman` in the repo
|
||||
(`npx skills add Ark0N/Codeman --skill codeman -g`, or `codeman skill install
|
||||
[--case <name>] [--mode <mode>]`, or the synced `agentSkillEnabled` App Setting for
|
||||
automatic per-case injection on session create, into the directory each CLI scans for
|
||||
skills). The skill carries the guard, the safety rules, and verified
|
||||
wait/orchestration recipes.
|
||||
[--case <name>]`, or the synced `agentSkillEnabled` App Setting for automatic
|
||||
per-case injection on Claude session create). The skill carries the guard, the
|
||||
safety rules, and verified wait/orchestration recipes.
|
||||
|
||||
The common ones:
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 548 KiB |
+7
-15
@@ -4,8 +4,8 @@ Saved, named jobs that start a session and send it a prompt on a schedule. Cron
|
||||
sessions: *every weekday at 03:00, open a Claude session in `~/proj` and tell it to update
|
||||
dependencies and open a PR.*
|
||||
|
||||
The ⏰ **Cron** button in the bottom toolbar is opt-in. Turn it on under
|
||||
**App Settings → Header & Panels → Scheduling**.
|
||||
The ⏰ **Cron** header button is opt-in. Turn it on in
|
||||
**App Settings → Header & Panels**.
|
||||
|
||||
## Creating a job
|
||||
|
||||
@@ -96,19 +96,11 @@ The skip policy has the details you would want it to have:
|
||||
|
||||
Every fire is recorded per job, with a status:
|
||||
|
||||
| Status | Meaning |
|
||||
| ----------------- | ----------------------------------------------------------------- |
|
||||
| `created` | The run record was created; the session has not started yet. |
|
||||
| `session_started` | The session started; prompt delivery is still pending. |
|
||||
| `prompt_sent` | The prompt was sent to the session. |
|
||||
| `skipped` | The concurrency policy blocked it. Not counted as a run. |
|
||||
| `failed` | Prompt resolution, session launch, or prompt delivery failed. |
|
||||
|
||||
History records whether the session started and the prompt was delivered, **not whether
|
||||
the agent's task succeeded**. `prompt_sent` is not a task-completion signal.
|
||||
If the session is closed during the readiness wait or the server restarts before
|
||||
delivery, a run can remain `session_started` indefinitely with `finishedAt: null`;
|
||||
clients must not poll forever waiting for a terminal status.
|
||||
| Status | Meaning |
|
||||
| --------- | -------------------------------------------------------------------- |
|
||||
| `created` | The run started and a session was created. |
|
||||
| `skipped` | The concurrency policy blocked it. Not counted as a run. |
|
||||
| `failed` | The prompt could not be resolved, or the working directory was gone. |
|
||||
|
||||
The schedule is advanced **before** the session launches, so a slow start cannot cause the
|
||||
same job to re-trigger.
|
||||
|
||||
@@ -4,34 +4,26 @@ Everything the dashboard does is HTTP, so an agent can do it too. This page is f
|
||||
that makes Codeman interesting: **Claude Code running inside a Codeman session, spawning and
|
||||
supervising other sessions.**
|
||||
|
||||
Three routes. Start with the skill, whichever CLI the agent is. Where the skill does not
|
||||
reach (a pi or shell session), use the `codeman agent` commands. Raw HTTP is there for
|
||||
everything else.
|
||||
Three routes. In a Claude session, start with the skill. In any other CLI mode, use the
|
||||
`codeman agent` commands. Raw HTTP is there for everything else.
|
||||
|
||||
## The agent skill
|
||||
|
||||
A skill in the `SKILL.md` format that Claude Code, Codex, Gemini CLI, OpenCode, Grok, OMP
|
||||
and most other agent CLIs read. It teaches the agent the whole API, so you ask in plain
|
||||
English instead of pasting endpoint documentation into prompts.
|
||||
A Claude Code skill that teaches the agent the whole API, so you ask in plain English
|
||||
instead of pasting endpoint documentation into prompts.
|
||||
|
||||
### Install it
|
||||
|
||||
| How | Command | Scope |
|
||||
| ------------ | ----------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, any skills-aware agent. The way to give it to pi. |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, any skills-aware agent. |
|
||||
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman`, then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager. `/plugin update codeman` follows releases. Pick this or `codeman skill install`, not both, or the skill is listed twice (`codeman` and `codeman:codeman`). |
|
||||
| Bundled CLI | `codeman skill install` | Global, at `~/.claude/skills/codeman`. |
|
||||
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case. `--mode codex` (or any other run mode) writes it where that CLI looks for skills. |
|
||||
| Web UI | **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent Skill** | Injects into each case when a session is created, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP. Not pi: a project skill makes pi ask to trust the folder at startup. Off by default. |
|
||||
| Bundled CLI | `codeman skill install --case <name>` | One case. |
|
||||
| Web UI | **App Settings → Agents & CLIs → Claude → Agent Skill** | Injects into each case when a Claude session is created. Off by default. |
|
||||
|
||||
`codeman skill uninstall [--case <name>]` reverses the CLI installs (with `--case`, every
|
||||
Codeman-managed copy in that case), and never touches a `skills/codeman` you wrote yourself.
|
||||
|
||||
Two things to know when the agent is **Codex**: its default sandbox blocks network access
|
||||
even to this machine, so the skill needs **Bypass approvals and sandbox** (App Settings,
|
||||
Codex); and Codex 0.162 runs its commands in a shared background process, whose Codeman
|
||||
variables can belong to the session that started it. The skill checks for both and says so
|
||||
instead of acting.
|
||||
`codeman skill uninstall [--case <name>]` reverses the CLI installs, and never touches a
|
||||
`skills/codeman` you wrote yourself.
|
||||
|
||||
### Then just ask
|
||||
|
||||
@@ -70,12 +62,12 @@ completion signals.
|
||||
|
||||
## The `codeman agent` commands
|
||||
|
||||
The skill drives the API with `curl` and `jq`. Where it does not reach (a pi or shell
|
||||
session, a machine without `jq`), the agent runs in the same environment, so `codeman agent`
|
||||
packages the same verbs as shell commands. It is a thin client over the endpoints in
|
||||
[the manual path](#the-manual-path), so auth and ownership apply unchanged, and it refuses to
|
||||
act outside a Codeman session. One line in a case's `AGENTS.md` is enough: *"other sessions:
|
||||
`codeman agent --help`"*.
|
||||
The skill is Claude-shaped: Codeman seeds its preamble for Claude sessions only. An
|
||||
`opencode`, `codex`, `pi` or `gemini` agent runs in the same environment but has nothing
|
||||
that teaches it the API, so `codeman agent` packages the same verbs as shell commands. It is
|
||||
a thin client over the endpoints in [the manual path](#the-manual-path), so auth and
|
||||
ownership apply unchanged, and it refuses to act outside a Codeman session. One line in a
|
||||
case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
|
||||
|
||||
```bash
|
||||
codeman agent ls # sessions; * marks this one
|
||||
|
||||
@@ -92,32 +92,6 @@ Roughly 235 handlers across 26 route modules. By domain:
|
||||
|
||||
Each route module documents its own endpoints in its file header.
|
||||
|
||||
## Cron jobs
|
||||
|
||||
Saved scheduled jobs are distinct from the legacy `/api/scheduled` loops. Their
|
||||
versioned endpoints are:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/cron/jobs` | List jobs |
|
||||
| POST | `/api/v1/cron/jobs` | Create a job |
|
||||
| GET | `/api/v1/cron/jobs/:id` | Read a job |
|
||||
| PUT | `/api/v1/cron/jobs/:id` | Update a job (partial body) |
|
||||
| DELETE | `/api/v1/cron/jobs/:id` | Delete a job |
|
||||
| PUT | `/api/v1/cron/jobs/:id/enabled` | Enable/disable with `{ enabled: boolean }` |
|
||||
| POST | `/api/v1/cron/jobs/:id/run` | Run now, without changing the schedule |
|
||||
| GET | `/api/v1/cron/jobs/:id/runs` | Read a job's run history |
|
||||
| GET | `/api/v1/cron/runs` | Read all accessible run history |
|
||||
|
||||
Responses use the envelope above: job lists/history have arrays in `data`, a
|
||||
single-job GET has the job itself, create/update/enable have `{ job }`, delete has
|
||||
`{}`, and Run Now has `{ run, activeAgents }`. Prompt delivery is asynchronous;
|
||||
`session_started` and `prompt_sent` describe launch/delivery, not task success.
|
||||
|
||||
The [cron API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#cron-jobs)
|
||||
lists all request and response fields, validation, and ownership restrictions.
|
||||
[Creating a job](Cron-Jobs) covers the UI and schedule semantics.
|
||||
|
||||
## Long-polling instead of polling
|
||||
|
||||
Three calls block until something happens, so an agent driving Codeman from a shell can wait
|
||||
|
||||
@@ -57,6 +57,11 @@ unattended. You can leave while it builds. What it asks you:
|
||||
4. **Whether to run Codeman in the background.** Enter installs a systemd user service or a
|
||||
macOS LaunchAgent that starts on boot; answering no offers to start it in this terminal
|
||||
instead, or not at all.
|
||||
5. **On a laptop only: keep it awake while Codeman runs?** Closing the lid suspends the
|
||||
machine, and every agent session freezes until it wakes. Yes turns on App Settings >
|
||||
System > **Power** (only while plugged in). On a Mac it also offers a small root helper,
|
||||
because macOS sleeps on lid close whatever an app asks; it needs your admin password
|
||||
once. The default is no, and `--yes` never turns it on.
|
||||
|
||||
It ends on a screen with the URL (your tailnet, your network, or this machine), a QR code to
|
||||
scan with your phone, and the two commands you need to manage the service.
|
||||
@@ -75,6 +80,7 @@ install.sh uninstall # remove (offers to undo a rename it performed)
|
||||
install.sh tailscale # retrofit Tailscale access onto an existing install
|
||||
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
|
||||
install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel
|
||||
install.sh keep-awake # keep this machine awake while Codeman runs (macOS: adds the lid helper)
|
||||
```
|
||||
|
||||
**Flags** answer the questions from the command line and pipe through `bash -s --`:
|
||||
|
||||
@@ -145,7 +145,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
|
||||
| Allowed Tools | The list used by the explicit mode. |
|
||||
| Ralph / Todo Tracker | Enables the Ralph loop surfaces. |
|
||||
| Agent Teams | Experimental teams. Also needs the CLI's own environment flag. |
|
||||
| Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi and a plain shell). Off by default. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). |
|
||||
| Codeman Agent Skill | Injects the agent skill into new Claude sessions per case. Off by default. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). |
|
||||
| Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. |
|
||||
| Nice priority / value | Runs agent processes at a lower CPU priority. |
|
||||
| Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. |
|
||||
@@ -181,8 +181,14 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
|
||||
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
|
||||
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs
|
||||
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
||||
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
||||
mode, the **Users** administration entry is injected here.
|
||||
tools with their versions and install hints (admin only in multi-user mode). The **Power**
|
||||
group keeps the machine Codeman runs on awake while Codeman runs (`keepAwakeEnabled`, off by
|
||||
default; **Only on AC power**, `keepAwakeAcOnly`, on by default), and shows what the lock is
|
||||
doing right now. On Linux it blocks lid-close suspend while someone is logged in to the
|
||||
desktop (the desktop's own Automatic Suspend timer is separate). On macOS it blocks idle
|
||||
sleep, and lid-close sleep too once the root helper from `install.sh keep-awake` is
|
||||
installed. Admin only in multi-user mode. In multi-user mode, the **Users** administration
|
||||
entry is injected here.
|
||||
|
||||
## Session Options
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ Two extras depending on the device:
|
||||
| Respawn | Session Options | [Keeping Agents Running](Keeping-Agents-Running) |
|
||||
| Ralph | Session Options | [Autonomous Loops](Autonomous-Loops) |
|
||||
| Orchestrator | Toolbar | [Autonomous Loops](Autonomous-Loops) |
|
||||
| Cron | Bottom toolbar ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) |
|
||||
| Cron | Header ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) |
|
||||
| Subagents | Automatic while agents run | [Watching Agents Work](Watching-Agents-Work) |
|
||||
| Ultracode | Header (opt-in) | [Watching Agents Work](Watching-Agents-Work) |
|
||||
| File Viewer | Header | [Working With Files](Working-With-Files) |
|
||||
|
||||
+258
-28
@@ -7,8 +7,9 @@
|
||||
#
|
||||
# The flow: look at what is already on the machine, ask at most three
|
||||
# questions (how the dashboard is reached, optionally what to call this
|
||||
# machine on your tailnet, whether to run Codeman as a service), then do all
|
||||
# the work unattended and end on the URL, with a QR code for your phone.
|
||||
# machine on your tailnet, whether to run Codeman as a service; on a laptop,
|
||||
# a follow-up on keeping it awake), then do all the work unattended and end on
|
||||
# the URL, with a QR code for your phone.
|
||||
#
|
||||
# Flags (each has an environment-variable twin, listed below):
|
||||
# --tailscale | --lan | --local How the dashboard is reached (question 1)
|
||||
@@ -48,6 +49,7 @@
|
||||
# install.sh name [<n>] - Rename this machine on your tailnet (default: codeman-<hostname>)
|
||||
# install.sh status - Print the URLs, the QR code and how to manage the service
|
||||
# install.sh cloudflared - Install cloudflared for the in-app Cloudflare tunnel
|
||||
# install.sh keep-awake - Keep this machine awake while Codeman runs (macOS: adds the lid helper)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -129,6 +131,18 @@ SUBCOMMAND_ARG=""
|
||||
# alone) | empty (no service manager here).
|
||||
LAUNCH_CHOICE="3"
|
||||
SERVICE_TYPE=""
|
||||
# Follow-up to question 3, laptops only (choose_keep_awake): KEEP_AWAKE=1
|
||||
# writes keepAwakeEnabled into settings.json, KEEP_AWAKE_LID_HELPER=1 installs
|
||||
# the macOS root helper that covers lid-close sleep. Empty = leave as is.
|
||||
KEEP_AWAKE=""
|
||||
KEEP_AWAKE_LID_HELPER=""
|
||||
# Where laptop detection looks. Variables only so the test harness can point
|
||||
# them at a fake tree.
|
||||
POWER_SUPPLY_ROOT="/sys/class/power_supply"
|
||||
LID_BUTTON_ROOT="/proc/acpi/button/lid"
|
||||
KEEP_AWAKE_HELPER_DIR="/Library/Application Support/Codeman"
|
||||
KEEP_AWAKE_HELPER_PLIST="/Library/LaunchDaemons/com.codeman.keepawake.plist"
|
||||
KEEP_AWAKE_HELPER_LABEL="com.codeman.keepawake"
|
||||
|
||||
# Everything the unattended steps print goes here; the terminal gets one line
|
||||
# per step and the tail of this file on failure.
|
||||
@@ -2705,6 +2719,200 @@ cloudflared_subcommand() {
|
||||
# Wait briefly for codeman-web.service to report active. A bad node path or a
|
||||
# busy port makes the unit crash within the first seconds (then sit in
|
||||
# activating/auto-restart), so a blind "started!" message would be a lie.
|
||||
# ----------------------------------------------------------------------------
|
||||
# Keep awake (laptops): Codeman holds an OS sleep lock while it runs
|
||||
# ----------------------------------------------------------------------------
|
||||
# The server does the work (src/keep-awake-manager.ts: systemd-inhibit on
|
||||
# Linux, caffeinate on macOS); the installer only asks, writes the setting and,
|
||||
# on macOS, installs the root helper for the one thing caffeinate cannot do:
|
||||
# keep a closed MacBook awake (scripts/keep-awake-macos.sh).
|
||||
|
||||
# True on a machine with its own battery or a lid. A peripheral's battery
|
||||
# (scope=Device, e.g. a wireless mouse) does not make a desktop a laptop.
|
||||
is_laptop() {
|
||||
local os="$1" d t scope
|
||||
if [[ "$os" == "macos" ]]; then
|
||||
pmset -g batt 2>/dev/null | grep -q 'InternalBattery'
|
||||
return
|
||||
fi
|
||||
if [[ -d "$LID_BUTTON_ROOT" ]] && [[ -n "$(ls -A "$LID_BUTTON_ROOT" 2>/dev/null)" ]]; then
|
||||
return 0
|
||||
fi
|
||||
for d in "$POWER_SUPPLY_ROOT"/*; do
|
||||
[[ -f "$d/type" ]] || continue
|
||||
t=$(cat "$d/type" 2>/dev/null || true)
|
||||
[[ "$t" == "Battery" ]] || continue
|
||||
scope=$(cat "$d/scope" 2>/dev/null || true)
|
||||
[[ "$scope" == "Device" ]] && continue
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
keep_awake_settings_file() {
|
||||
printf '%s\n' "$HOME/.codeman/settings.json"
|
||||
}
|
||||
|
||||
# The server writes settings.json with JSON.stringify(…, null, 2).
|
||||
keep_awake_enabled_now() {
|
||||
grep -q '"keepAwakeEnabled": *true' "$(keep_awake_settings_file)" 2>/dev/null
|
||||
}
|
||||
|
||||
# Asked right after question 3, on laptops only ($2 = force skips that check,
|
||||
# for `install.sh keep-awake`). The default is no, so --yes and headless runs
|
||||
# never turn it on: keeping a machine awake is the owner's call.
|
||||
choose_keep_awake() {
|
||||
local os="$1" force="${2:-}"
|
||||
KEEP_AWAKE=""
|
||||
KEEP_AWAKE_LID_HELPER=""
|
||||
if [[ "$force" != "force" ]]; then
|
||||
is_laptop "$os" || return 0
|
||||
fi
|
||||
if keep_awake_enabled_now; then
|
||||
info "Keep-awake is already on (App Settings > System > Power)."
|
||||
else
|
||||
echo -e " ${DIM}Closing the lid suspends the machine, and every agent session freezes until it wakes.${NC}" >&2
|
||||
prompt_yes_no "Keep this machine awake while Codeman runs, even with the lid closed (only while plugged in)?" "n" || return 0
|
||||
KEEP_AWAKE="1"
|
||||
fi
|
||||
# The helper needs sudo, so it is only ever offered to a person at a terminal.
|
||||
if [[ "$os" == "macos" ]] && [[ ! -f "$KEEP_AWAKE_HELPER_PLIST" ]] &&
|
||||
[[ "$NONINTERACTIVE" != "1" && "$ASSUME_YES" != "1" ]] && has_tty; then
|
||||
echo -e " ${DIM}macOS sleeps on lid close whatever an app asks. A small root helper (pmset disablesleep) covers that, only while Codeman runs.${NC}" >&2
|
||||
if prompt_yes_no "Install the lid helper? (asks for your admin password once)" "y"; then
|
||||
KEEP_AWAKE_LID_HELPER="1"
|
||||
sudo_session_start
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Turn the setting on before the service starts, so its first boot already
|
||||
# holds the lock. node does the JSON; a settings file that does not parse is
|
||||
# left untouched.
|
||||
write_keep_awake_setting() {
|
||||
local file
|
||||
file=$(keep_awake_settings_file)
|
||||
mkdir -p "$(dirname "$file")"
|
||||
if node -e '
|
||||
const fs = require("fs");
|
||||
const p = process.argv[1];
|
||||
let s = {};
|
||||
try { s = JSON.parse(fs.readFileSync(p, "utf8")); }
|
||||
catch (e) { if (e.code !== "ENOENT") process.exit(2); }
|
||||
if (s === null || typeof s !== "object" || Array.isArray(s)) process.exit(2);
|
||||
s.keepAwakeEnabled = true;
|
||||
if (s.keepAwakeAcOnly === undefined) s.keepAwakeAcOnly = true;
|
||||
fs.writeFileSync(p, JSON.stringify(s, null, 2));
|
||||
' "$file" </dev/null; then
|
||||
success "Keep-awake is on (only while plugged in; App Settings > System > Power)"
|
||||
return 0
|
||||
fi
|
||||
warn "Could not turn keep-awake on: $file is not valid JSON. Use App Settings > System > Power instead."
|
||||
return 1
|
||||
}
|
||||
|
||||
# macOS: copy the helper to a ROOT-OWNED path (a root daemon must never run a
|
||||
# file the user can edit, and the install dir is the user's) and load it as a
|
||||
# LaunchDaemon that runs every 20 seconds. It only acts while the server keeps
|
||||
# a fresh request file in the data dir.
|
||||
install_keep_awake_lid_helper() {
|
||||
local src="$INSTALL_DIR/scripts/keep-awake-macos.sh"
|
||||
local script="$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh"
|
||||
local request="$HOME/.codeman/keep-awake-lid.pid"
|
||||
local tmp
|
||||
if [[ ! -f "$src" ]]; then
|
||||
warn "Lid helper not found at $src; skipped."
|
||||
return 1
|
||||
fi
|
||||
if ! { run_as_root mkdir -p "$KEEP_AWAKE_HELPER_DIR" &&
|
||||
run_as_root chown root:wheel "$KEEP_AWAKE_HELPER_DIR" &&
|
||||
run_as_root chmod 755 "$KEEP_AWAKE_HELPER_DIR" &&
|
||||
run_as_root install -m 755 -o root -g wheel "$src" "$script"; }; then
|
||||
warn "Could not install the lid helper (sudo refused?). Run later: install.sh keep-awake"
|
||||
return 1
|
||||
fi
|
||||
tmp=$(mktemp)
|
||||
cat > "$tmp" << EOF
|
||||
<?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>$KEEP_AWAKE_HELPER_LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<string>$(xml_escape "$script")</string>
|
||||
<string>$(xml_escape "$request")</string>
|
||||
</array>
|
||||
<key>StartInterval</key>
|
||||
<integer>20</integer>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
run_as_root launchctl unload "$KEEP_AWAKE_HELPER_PLIST" 2>/dev/null || true
|
||||
if ! run_as_root install -m 644 -o root -g wheel "$tmp" "$KEEP_AWAKE_HELPER_PLIST"; then
|
||||
rm -f "$tmp"
|
||||
warn "Could not write $KEEP_AWAKE_HELPER_PLIST. Run later: install.sh keep-awake"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
run_as_root launchctl load -w "$KEEP_AWAKE_HELPER_PLIST" 2>/dev/null || true
|
||||
if run_as_root launchctl list "$KEEP_AWAKE_HELPER_LABEL" &>/dev/null; then
|
||||
success "Lid helper installed (it only acts while Codeman asks it to)"
|
||||
return 0
|
||||
fi
|
||||
warn "The lid helper did not load. Inspect: sudo launchctl list $KEEP_AWAKE_HELPER_LABEL"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Uninstall: undo a disablesleep only the helper set (its .owned marker, never
|
||||
# an administrator's own setting), then remove the daemon and its files.
|
||||
remove_keep_awake_lid_helper() {
|
||||
[[ -f "$KEEP_AWAKE_HELPER_PLIST" || -d "$KEEP_AWAKE_HELPER_DIR" ]] || return 0
|
||||
run_as_root launchctl unload "$KEEP_AWAKE_HELPER_PLIST" 2>/dev/null || true
|
||||
if [[ -f "$KEEP_AWAKE_HELPER_DIR/keep-awake.owned" ]]; then
|
||||
run_as_root pmset -a disablesleep 0 2>/dev/null || true
|
||||
fi
|
||||
run_as_root rm -f "$KEEP_AWAKE_HELPER_PLIST" "$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh" \
|
||||
"$KEEP_AWAKE_HELPER_DIR/keep-awake.owned" 2>/dev/null || true
|
||||
run_as_root rmdir "$KEEP_AWAKE_HELPER_DIR" 2>/dev/null || true
|
||||
success "Removed the keep-awake lid helper"
|
||||
}
|
||||
|
||||
# Work-phase half of choose_keep_awake. Both steps are optional extras: a
|
||||
# failure warns and the install carries on.
|
||||
apply_keep_awake() {
|
||||
if [[ "$KEEP_AWAKE" == "1" ]]; then
|
||||
write_keep_awake_setting || true
|
||||
fi
|
||||
if [[ "$KEEP_AWAKE_LID_HELPER" == "1" ]]; then
|
||||
install_keep_awake_lid_helper || true
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# `install.sh keep-awake`: turn it on for an existing install (no laptop check:
|
||||
# asking for it is the answer), add the macOS lid helper, then restart the
|
||||
# service so the running server picks it up.
|
||||
keep_awake_subcommand() {
|
||||
print_banner
|
||||
command -v node &>/dev/null || die "node is required. Install Codeman first (run the installer without arguments)."
|
||||
local os
|
||||
os=$(detect_os)
|
||||
choose_keep_awake "$os" force
|
||||
if [[ -z "$KEEP_AWAKE" && -z "$KEEP_AWAKE_LID_HELPER" ]]; then
|
||||
info "Nothing changed."
|
||||
return 0
|
||||
fi
|
||||
apply_keep_awake
|
||||
echo ""
|
||||
restart_running_service
|
||||
}
|
||||
|
||||
verify_systemd_active() {
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
@@ -3032,6 +3240,7 @@ main() {
|
||||
choose_network_binding
|
||||
echo ""
|
||||
choose_launch_mode "$os"
|
||||
choose_keep_awake "$os"
|
||||
echo ""
|
||||
|
||||
# ========================================================================
|
||||
@@ -3050,6 +3259,9 @@ main() {
|
||||
# (symlinks, PATH, launch menu) instead of silently "updating".
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete"
|
||||
|
||||
# Before the service starts, so its first boot already reads the setting.
|
||||
apply_keep_awake
|
||||
|
||||
local service_ok="true"
|
||||
if [[ "$LAUNCH_CHOICE" == "2" ]]; then
|
||||
if [[ "$SERVICE_TYPE" == "launchd" ]]; then
|
||||
@@ -3493,6 +3705,36 @@ print_done_screen() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Restart Codeman under whatever supervises it, so a new build or a changed
|
||||
# setting takes effect; otherwise say how. Shared by update and keep-awake.
|
||||
restart_running_service() {
|
||||
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
|
||||
info "Restarting codeman-web service..."
|
||||
systemctl --user restart codeman-web.service 2>/dev/null || true
|
||||
if verify_systemd_active; then
|
||||
success "codeman-web service restarted"
|
||||
else
|
||||
warn "codeman-web.service did not come back up."
|
||||
warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e"
|
||||
fi
|
||||
elif [[ -f "$agent_plist" ]]; then
|
||||
info "Restarting LaunchAgent..."
|
||||
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||
launchctl load "$agent_plist" 2>/dev/null || true
|
||||
success "LaunchAgent restarted"
|
||||
elif [[ -f "/Library/LaunchDaemons/com.codeman.web.plist" ]]; then
|
||||
# Left alone on purpose (see setup_launchd_service); it keeps running
|
||||
# the previous build until its owner restarts it.
|
||||
info "A system LaunchDaemon supervises Codeman; restart it to apply:"
|
||||
echo -e " ${CYAN}sudo launchctl kickstart -k system/com.codeman.web${NC}"
|
||||
else
|
||||
echo -e " ${DIM}Restart codeman web to apply:${NC}"
|
||||
echo -e " ${CYAN}codeman web --stop; codeman web -d${NC}"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
update() {
|
||||
if [[ ! -d "$INSTALL_DIR/.git" ]]; then
|
||||
die "Codeman is not installed at $INSTALL_DIR. Run the installer first."
|
||||
@@ -3525,32 +3767,15 @@ update() {
|
||||
success "Updated to $(node -e "console.log(require('./package.json').version)")"
|
||||
echo ""
|
||||
|
||||
# Auto-restart service if running, otherwise tell the user
|
||||
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
|
||||
info "Restarting codeman-web service..."
|
||||
systemctl --user restart codeman-web.service 2>/dev/null || true
|
||||
if verify_systemd_active; then
|
||||
success "codeman-web service restarted"
|
||||
else
|
||||
warn "codeman-web.service did not come back up."
|
||||
warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e"
|
||||
fi
|
||||
elif [[ -f "$agent_plist" ]]; then
|
||||
info "Restarting LaunchAgent..."
|
||||
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||
launchctl load "$agent_plist" 2>/dev/null || true
|
||||
success "LaunchAgent restarted"
|
||||
elif [[ -f "/Library/LaunchDaemons/com.codeman.web.plist" ]]; then
|
||||
# Left alone on purpose (see setup_launchd_service); it keeps running
|
||||
# the previous build until its owner restarts it.
|
||||
info "A system LaunchDaemon supervises Codeman; restart it to run the new build:"
|
||||
echo -e " ${CYAN}sudo launchctl kickstart -k system/com.codeman.web${NC}"
|
||||
else
|
||||
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
|
||||
echo -e " ${CYAN}codeman web --stop; codeman web -d${NC}"
|
||||
restart_running_service
|
||||
|
||||
# The lid helper runs from a root-owned copy, so an update never reaches it
|
||||
# on its own (and must not: that would need sudo here). Say so when it drifted.
|
||||
if [[ -f "$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh" ]] &&
|
||||
! cmp -s "$INSTALL_DIR/scripts/keep-awake-macos.sh" "$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh"; then
|
||||
info "The keep-awake lid helper has a newer version. Refresh it with: install.sh keep-awake"
|
||||
echo ""
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Reflect the service's actual binding in the closing notice. Updates
|
||||
# never rewrite the service files, so the existing choice is authoritative.
|
||||
@@ -3603,6 +3828,9 @@ uninstall() {
|
||||
rm -f "$agent_plist"
|
||||
success "Removed LaunchAgent"
|
||||
fi
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
remove_keep_awake_lid_helper
|
||||
fi
|
||||
if [[ -f "$daemon_plist" ]]; then
|
||||
# This installer never writes a LaunchDaemon (setup_launchd_service
|
||||
# leaves one alone), so this one is the user's own headless-Mac setup:
|
||||
@@ -3721,6 +3949,7 @@ Subcommands
|
||||
name [<n>] Rename this machine on your tailnet (default: codeman-<hostname>)
|
||||
status Print the URLs, the QR code and how to manage the service
|
||||
cloudflared Install cloudflared for the in-app Cloudflare tunnel
|
||||
keep-awake Keep this machine awake while Codeman runs (macOS: adds the lid helper)
|
||||
|
||||
Environment: CODEMAN_NONINTERACTIVE=1, CODEMAN_INSTALL_DIR, CODEMAN_HOST,
|
||||
CODEMAN_PASSWORD, CODEMAN_PORT, CODEMAN_TAILSCALE=1, CODEMAN_TAILSCALE_NAME,
|
||||
@@ -3762,7 +3991,7 @@ parse_flags() {
|
||||
CODEMAN_PORT="$1"; export CODEMAN_PORT; RECONFIGURE="1" ;;
|
||||
--port=*) CODEMAN_PORT="${1#--port=}"; export CODEMAN_PORT; RECONFIGURE="1" ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
update|uninstall|tailscale|name|status|cloudflared)
|
||||
update|uninstall|tailscale|name|status|cloudflared|keep-awake)
|
||||
[[ -z "$SUBCOMMAND" ]] || die "Only one subcommand at a time ($SUBCOMMAND and $1 given)."
|
||||
SUBCOMMAND="$1" ;;
|
||||
-*) die "Unknown option: $1 (see --help)" ;;
|
||||
@@ -3795,6 +4024,7 @@ case "$SUBCOMMAND" in
|
||||
name) setup_name_subcommand ;;
|
||||
status) status_subcommand ;;
|
||||
cloudflared) cloudflared_subcommand ;;
|
||||
keep-awake) keep_awake_subcommand ;;
|
||||
*)
|
||||
# Only a COMPLETED install re-runs as a quiet update. A partial one
|
||||
# (clone succeeded but build/menu never finished) lacks the marker and
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
---
|
||||
name: codeman
|
||||
description: >-
|
||||
Drive Codeman, the session manager this agent is running inside, over its HTTP API,
|
||||
from any agent CLI (Claude Code, Codex, Gemini CLI, OpenCode, Antigravity, Grok
|
||||
Build, DeepSeek Harness, OMP): list sessions, start worker sessions in any installed
|
||||
CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
|
||||
read their output, and clean up; where available, message claude workers directly
|
||||
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
|
||||
work across Codeman sessions, get a second opinion from another CLI or model, watch
|
||||
Drive Codeman, the session manager this agent is running inside, over its HTTP API:
|
||||
list sessions, start worker sessions, send them prompts, block until they finish
|
||||
(wait / wait-output / send-and-wait), read their output, and clean up; where
|
||||
available, message claude workers directly (Claude Code cross-session messaging).
|
||||
Use when asked to orchestrate or parallelize work across Codeman sessions, watch
|
||||
another session, or start and manage workers. Only usable inside a Codeman-managed
|
||||
session (CODEMAN_MUX=1); refuse to act otherwise.
|
||||
---
|
||||
@@ -31,31 +29,17 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
|
||||
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
|
||||
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
|
||||
|
||||
This skill works the same whichever CLI you are: Codeman puts it where each CLI looks
|
||||
for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
|
||||
OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
|
||||
it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
|
||||
That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same
|
||||
verbs over the same endpoints, with the guards below (no control bytes, no self-delete,
|
||||
no guessed URL) enforced in code, and it is also your own way through when this
|
||||
machine has no `jq` for the preamble below.
|
||||
Workers in **every other mode** never receive this preamble, but they have the same
|
||||
environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI
|
||||
(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the
|
||||
same endpoints, with the guards below (no control bytes, no self-delete, no guessed
|
||||
URL) enforced in code.
|
||||
|
||||
## 0. Guard and bootstrap
|
||||
|
||||
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
||||
you are not part of is not yours to drive.
|
||||
|
||||
Two failures come from your CLI, not from Codeman: name them to the user instead of
|
||||
retrying.
|
||||
|
||||
- **Every call fails at once with "couldn't connect" or "connection refused".** Your
|
||||
CLI's sandbox blocks network access, even to this machine. Codex's default sandbox
|
||||
does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex).
|
||||
- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints
|
||||
`404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background
|
||||
process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and
|
||||
the delete guard) is that session's. Delete nothing; tell the user.
|
||||
|
||||
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
|
||||
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
|
||||
the next call, and `$$` is a different pid. **The filesystem does survive**, so write
|
||||
@@ -63,9 +47,8 @@ the preamble to a file once and source it afterwards, rather than re-pasting a
|
||||
hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the
|
||||
single most likely way to break a run).
|
||||
|
||||
**Codeman seeds the preamble file for you** when it spawns your session (claude since
|
||||
server 1.18.3, the other CLIs since this skill reached them), so the bootstrap is
|
||||
usually nothing at all: these are the two lines every
|
||||
**Codeman seeds the preamble file for you** when it spawns a claude session (server
|
||||
1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every
|
||||
later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
# keep-awake-macos.sh: root helper behind Codeman's "Keep this computer awake" on macOS.
|
||||
#
|
||||
# caffeinate (which the server runs itself, no root) stops idle sleep but not lid-close
|
||||
# sleep. Only `pmset -a disablesleep 1` keeps a closed MacBook awake, and that is a
|
||||
# machine-wide root setting with no owner process. So this helper, run by the
|
||||
# com.codeman.keepawake LaunchDaemon every 20 seconds, applies it while the server asks
|
||||
# for it and undoes it when the server stops asking:
|
||||
#
|
||||
# - The server writes its pid to the request file and rewrites it every 30 seconds
|
||||
# while it wants the lid covered (setting on, and on AC when "only on AC" is on).
|
||||
# - The request counts only while the file is fresh (under 2 minutes old) AND its pid
|
||||
# is alive AND that process belongs to the file's owner. A crashed or hung server
|
||||
# therefore releases the lid within about two minutes on its own.
|
||||
# - The helper undoes only a disablesleep it set itself (tracked by the .owned file),
|
||||
# so an administrator's own `pmset -a disablesleep 1` is never switched off.
|
||||
#
|
||||
# install.sh copies this file to a ROOT-OWNED path before the daemon runs it; never run
|
||||
# it from the user-writable install directory.
|
||||
#
|
||||
# Usage: keep-awake-macos.sh <request-file>
|
||||
# Test hooks (set only by the test suite): CODEMAN_KEEPAWAKE_PMSET, CODEMAN_KEEPAWAKE_STATE_DIR.
|
||||
|
||||
set -u
|
||||
|
||||
REQ="${1:?usage: keep-awake-macos.sh <request-file>}"
|
||||
PMSET="${CODEMAN_KEEPAWAKE_PMSET:-/usr/bin/pmset}"
|
||||
STATE_DIR="${CODEMAN_KEEPAWAKE_STATE_DIR:-/Library/Application Support/Codeman}"
|
||||
OWNED="$STATE_DIR/keep-awake.owned"
|
||||
|
||||
want=0
|
||||
# -L: refuse a symlink (root must not be steered to read some other file).
|
||||
# find -mmin -2: modified within the last 2 minutes (BSD and GNU find both support it).
|
||||
if [ -f "$REQ" ] && [ ! -L "$REQ" ] && [ -n "$(find "$REQ" -mmin -2 2>/dev/null)" ]; then
|
||||
pid=$(head -c 32 "$REQ" 2>/dev/null | tr -dc '0-9')
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
file_uid=$(ls -ln "$REQ" 2>/dev/null | awk '{print $3}')
|
||||
pid_uid=$(ps -o uid= -p "$pid" 2>/dev/null | tr -d ' ')
|
||||
if [ -n "$file_uid" ] && [ "$file_uid" = "$pid_uid" ]; then
|
||||
want=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
current=$("$PMSET" -g 2>/dev/null | awk '/SleepDisabled/ {print $2; exit}')
|
||||
[ -n "$current" ] || current=0
|
||||
|
||||
if [ "$want" = "1" ]; then
|
||||
# Already 1 without our marker means an administrator set it: leave it and do not
|
||||
# claim it, so releasing later never switches their setting off.
|
||||
if [ "$current" != "1" ]; then
|
||||
if "$PMSET" -a disablesleep 1; then
|
||||
mkdir -p "$STATE_DIR" && : > "$OWNED"
|
||||
fi
|
||||
fi
|
||||
elif [ -f "$OWNED" ]; then
|
||||
if [ "$current" = "1" ]; then
|
||||
"$PMSET" -a disablesleep 0 || exit 1
|
||||
fi
|
||||
rm -f "$OWNED"
|
||||
fi
|
||||
exit 0
|
||||
+12
-29
@@ -1,13 +1,11 @@
|
||||
---
|
||||
name: codeman
|
||||
description: >-
|
||||
Drive Codeman, the session manager this agent is running inside, over its HTTP API,
|
||||
from any agent CLI (Claude Code, Codex, Gemini CLI, OpenCode, Antigravity, Grok
|
||||
Build, DeepSeek Harness, OMP): list sessions, start worker sessions in any installed
|
||||
CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
|
||||
read their output, and clean up; where available, message claude workers directly
|
||||
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
|
||||
work across Codeman sessions, get a second opinion from another CLI or model, watch
|
||||
Drive Codeman, the session manager this agent is running inside, over its HTTP API:
|
||||
list sessions, start worker sessions, send them prompts, block until they finish
|
||||
(wait / wait-output / send-and-wait), read their output, and clean up; where
|
||||
available, message claude workers directly (Claude Code cross-session messaging).
|
||||
Use when asked to orchestrate or parallelize work across Codeman sessions, watch
|
||||
another session, or start and manage workers. Only usable inside a Codeman-managed
|
||||
session (CODEMAN_MUX=1); refuse to act otherwise.
|
||||
---
|
||||
@@ -31,31 +29,17 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
|
||||
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
|
||||
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
|
||||
|
||||
This skill works the same whichever CLI you are: Codeman puts it where each CLI looks
|
||||
for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
|
||||
OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
|
||||
it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
|
||||
That bundled CLI (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same
|
||||
verbs over the same endpoints, with the guards below (no control bytes, no self-delete,
|
||||
no guessed URL) enforced in code, and it is also your own way through when this
|
||||
machine has no `jq` for the preamble below.
|
||||
Workers in **every other mode** never receive this preamble, but they have the same
|
||||
environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI
|
||||
(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the
|
||||
same endpoints, with the guards below (no control bytes, no self-delete, no guessed
|
||||
URL) enforced in code.
|
||||
|
||||
## 0. Guard and bootstrap
|
||||
|
||||
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
||||
you are not part of is not yours to drive.
|
||||
|
||||
Two failures come from your CLI, not from Codeman: name them to the user instead of
|
||||
retrying.
|
||||
|
||||
- **Every call fails at once with "couldn't connect" or "connection refused".** Your
|
||||
CLI's sandbox blocks network access, even to this machine. Codex's default sandbox
|
||||
does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex).
|
||||
- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints
|
||||
`404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background
|
||||
process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and
|
||||
the delete guard) is that session's. Delete nothing; tell the user.
|
||||
|
||||
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
|
||||
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
|
||||
the next call, and `$$` is a different pid. **The filesystem does survive**, so write
|
||||
@@ -63,9 +47,8 @@ the preamble to a file once and source it afterwards, rather than re-pasting a
|
||||
hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the
|
||||
single most likely way to break a run).
|
||||
|
||||
**Codeman seeds the preamble file for you** when it spawns your session (claude since
|
||||
server 1.18.3, the other CLIs since this skill reached them), so the bootstrap is
|
||||
usually nothing at all: these are the two lines every
|
||||
**Codeman seeds the preamble file for you** when it spawns a claude session (server
|
||||
1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every
|
||||
later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
|
||||
+13
-97
@@ -18,16 +18,7 @@ import { dataPath } from './config/instance.js';
|
||||
import { readCodemanCredentials } from './codeman-credentials.js';
|
||||
import { casePath } from './config/cases-dir.js';
|
||||
import { assertValidBasePath } from './config/base-path.js';
|
||||
import {
|
||||
agentSkillDir,
|
||||
agentSkillDirFor,
|
||||
CLAUDE_AGENT_SKILL_DIR,
|
||||
installAgentSkillInto,
|
||||
removeAgentSkillFrom,
|
||||
type AgentSkillApplyResult,
|
||||
} from './hooks-config.js';
|
||||
import type { AgentSkillDir } from './config/cli-registry/types.js';
|
||||
import { getCli, listClis } from './config/cli-registry/registry.js';
|
||||
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
|
||||
import { registerAgentCommands } from './cli-agent.js';
|
||||
import { getSessionManager } from './session-manager.js';
|
||||
import { getTaskQueue } from './task-queue.js';
|
||||
@@ -149,8 +140,7 @@ export function resolveCliCasePath(name: string): string {
|
||||
/**
|
||||
* Resolve where `skill install` / `skill uninstall` operate. Global is
|
||||
* `~/.claude/skills/codeman` (Claude Code's user-scope skill dir, read by every new
|
||||
* session); `--case <name>` targets `<case>/<skillDir>/codeman` for the given skills
|
||||
* directory (default: Claude Code's, `<case>/.claude/skills/codeman`), resolved through
|
||||
* session); `--case <name>` targets `<case>/.claude/skills/codeman`, resolved through
|
||||
* `resolveCliCasePath()` above. The web server's automatic per-case injection
|
||||
* (`agentSkillEnabled`) covers multi-user spaces; this CLI is a local operator tool
|
||||
* and stays single-user.
|
||||
@@ -161,18 +151,17 @@ export function resolveCliCasePath(name: string): string {
|
||||
*/
|
||||
export function resolveSkillTargetPath(options: {
|
||||
case?: string;
|
||||
skillDir?: AgentSkillDir;
|
||||
}): { target: string; missingCase?: undefined } | { target?: undefined; missingCase: string } {
|
||||
if (options.case) {
|
||||
const casePath = resolveCliCasePath(options.case);
|
||||
if (!existsSync(casePath)) return { missingCase: casePath };
|
||||
return { target: agentSkillDirFor(casePath, options.skillDir ?? CLAUDE_AGENT_SKILL_DIR) };
|
||||
return { target: join(casePath, '.claude', 'skills', 'codeman') };
|
||||
}
|
||||
return { target: join(homedir(), '.claude', 'skills', 'codeman') };
|
||||
}
|
||||
|
||||
/** Exit-owning wrapper around `resolveSkillTargetPath()` for the two commands below. */
|
||||
function resolveSkillTarget(options: { case?: string; skillDir?: AgentSkillDir }): string {
|
||||
function resolveSkillTarget(options: { case?: string }): string {
|
||||
const resolved = resolveSkillTargetPath(options);
|
||||
if (resolved.missingCase !== undefined) {
|
||||
console.error(palette.err(`✗ Case not found: ${resolved.missingCase}`));
|
||||
@@ -181,44 +170,8 @@ function resolveSkillTarget(options: { case?: string; skillDir?: AgentSkillDir }
|
||||
return resolved.target;
|
||||
}
|
||||
|
||||
/**
|
||||
* The skills directory `skill install --mode <mode>` writes into, or why it cannot: a
|
||||
* mode the registry gives no skill, or a user-level install for a CLI other than Claude
|
||||
* Code (each CLI keeps its user-level skills somewhere else, and `npx skills add`
|
||||
* already knows every one of those places).
|
||||
*/
|
||||
export function resolveSkillInstallDir(options: {
|
||||
mode?: string;
|
||||
case?: string;
|
||||
}): { skillDir: AgentSkillDir; error?: undefined } | { skillDir?: undefined; error: string } {
|
||||
const mode = options.mode ?? 'claude';
|
||||
const cli = getCli(mode);
|
||||
if (!cli) return { error: `Unknown mode: ${mode}` };
|
||||
const skillDir = agentSkillDir(cli.capabilities);
|
||||
if (!skillDir) return { error: `Mode ${mode} takes no Codeman agent skill` };
|
||||
if (!options.case && skillDir !== CLAUDE_AGENT_SKILL_DIR) {
|
||||
return {
|
||||
error: `A user-level install for ${mode} goes through the skills installer: npx skills add Ark0N/Codeman --skill codeman -g (or pass --case <name>)`,
|
||||
};
|
||||
}
|
||||
return { skillDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every distinct skills directory the registry can inject into, so
|
||||
* `skill uninstall --case` removes whatever any CLI's session left there.
|
||||
*/
|
||||
export function registryAgentSkillDirs(): AgentSkillDir[] {
|
||||
const dirs = new Set<AgentSkillDir>();
|
||||
for (const cli of listClis()) {
|
||||
const dir = agentSkillDir(cli.capabilities);
|
||||
if (dir) dirs.add(dir);
|
||||
}
|
||||
return [...dirs];
|
||||
}
|
||||
|
||||
/** An AgentSkillApplyResult in words, and whether it counts as success. */
|
||||
function describeSkillResult(result: AgentSkillApplyResult, target: string): { ok: boolean; text: string } {
|
||||
/** Print an AgentSkillApplyResult for humans; exit non-zero when nothing was done. */
|
||||
function reportSkillResult(result: AgentSkillApplyResult, target: string): void {
|
||||
const messages: Record<AgentSkillApplyResult, { ok: boolean; text: string }> = {
|
||||
installed: { ok: true, text: `Agent skill installed: ${target}` },
|
||||
refreshed: { ok: true, text: `Agent skill refreshed (was stale): ${target}` },
|
||||
@@ -234,12 +187,7 @@ function describeSkillResult(result: AgentSkillApplyResult, target: string): { o
|
||||
text: `${target} (or its parent) is a symlink, refusing to write through it.`,
|
||||
},
|
||||
};
|
||||
return messages[result];
|
||||
}
|
||||
|
||||
/** Print an AgentSkillApplyResult for humans; exit non-zero when nothing was done. */
|
||||
function reportSkillResult(result: AgentSkillApplyResult, target: string): void {
|
||||
const message = describeSkillResult(result, target);
|
||||
const message = messages[result];
|
||||
if (message.ok) {
|
||||
console.log(palette.ok(`✓ ${message.text}`));
|
||||
} else {
|
||||
@@ -257,19 +205,9 @@ skillCmd
|
||||
.description('Install the agent skill globally (~/.claude/skills/codeman) or into one case')
|
||||
.option('-g, --global', 'Install into ~/.claude/skills/codeman, picked up by every new session (the default)')
|
||||
.option('-c, --case <name>', 'Install into <case>/.claude/skills/codeman instead (linked cases resolve too)')
|
||||
.option(
|
||||
'-m, --mode <mode>',
|
||||
'With --case: install where that run mode looks for skills (codex: <case>/.agents/skills/codeman)',
|
||||
'claude'
|
||||
)
|
||||
.action(async (options: { global?: boolean; case?: string; mode?: string }) => {
|
||||
.action(async (options: { global?: boolean; case?: string }) => {
|
||||
try {
|
||||
const resolved = resolveSkillInstallDir(options);
|
||||
if (resolved.error !== undefined) {
|
||||
console.error(palette.err(`✗ ${resolved.error}`));
|
||||
process.exit(1);
|
||||
}
|
||||
const target = resolveSkillTarget({ case: options.case, skillDir: resolved.skillDir });
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await installAgentSkillInto(target), target);
|
||||
} catch (err) {
|
||||
console.error(palette.err(`✗ Failed to install agent skill: ${getErrorMessage(err)}`));
|
||||
@@ -279,35 +217,13 @@ skillCmd
|
||||
|
||||
skillCmd
|
||||
.command('uninstall')
|
||||
.description('Remove Codeman-managed agent skill copies (never touches a user-authored one)')
|
||||
.description('Remove a Codeman-managed agent skill copy (never touches a user-authored one)')
|
||||
.option('-g, --global', 'Remove from ~/.claude/skills/codeman (the default)')
|
||||
.option(
|
||||
'-c, --case <name>',
|
||||
"Remove every Codeman-managed copy from that case instead, whichever CLI's directory it is in (linked cases resolve too)"
|
||||
)
|
||||
.option('-c, --case <name>', 'Remove from <case>/.claude/skills/codeman instead (linked cases resolve too)')
|
||||
.action(async (options: { global?: boolean; case?: string }) => {
|
||||
try {
|
||||
if (!options.case) {
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await removeAgentSkillFrom(target), target);
|
||||
return;
|
||||
}
|
||||
let failed = false;
|
||||
let removedAny = false;
|
||||
for (const skillDir of registryAgentSkillDirs()) {
|
||||
const target = resolveSkillTarget({ case: options.case, skillDir });
|
||||
const result = await removeAgentSkillFrom(target);
|
||||
if (result === 'absent') continue;
|
||||
removedAny = true;
|
||||
const message = describeSkillResult(result, target);
|
||||
if (message.ok) console.log(palette.ok(`✓ ${message.text}`));
|
||||
else {
|
||||
console.error(palette.err(`✗ ${message.text}`));
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
if (!removedAny) console.log(palette.ok(`✓ No Codeman-managed agent skill in case ${options.case}`));
|
||||
if (failed) process.exit(1);
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await removeAgentSkillFrom(target), target);
|
||||
} catch (err) {
|
||||
console.error(palette.err(`✗ Failed to remove agent skill: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
|
||||
@@ -15,10 +15,7 @@
|
||||
import { z } from 'zod';
|
||||
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||
import type { AgentSkillDir, LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js';
|
||||
|
||||
/** `capabilities.agentSkillDir`: every value must stay a fixed, relative, `..`-free path. */
|
||||
export const AGENT_SKILL_DIRS = ['.claude/skills', '.agents/skills'] as const satisfies readonly AgentSkillDir[];
|
||||
import type { LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js';
|
||||
|
||||
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||
const cliId = z
|
||||
@@ -305,9 +302,6 @@ const capabilitiesSchema = z
|
||||
respawn: z.boolean(),
|
||||
effort: z.boolean(),
|
||||
agentSkillInjection: z.boolean(),
|
||||
// A closed list on purpose: it decides where the server WRITES, and this file is
|
||||
// user-editable, so it can never be a free path (`..`, absolute).
|
||||
agentSkillDir: z.enum(AGENT_SKILL_DIRS).optional(),
|
||||
statusLineTelemetry: z.boolean(),
|
||||
// How many columns this CLI indents its transcript body by, so a copy can take
|
||||
// that much off the clipboard. Bounded, because it is the whole strip: a copy
|
||||
|
||||
@@ -76,21 +76,6 @@ function agentDefaults(): Pick<
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent skill in `<workspace>/.agents/skills/codeman`, the one project skills
|
||||
* directory every CLI that spreads this scans. Measured per CLI on
|
||||
* 2026-10-11 against the installed builds or their source: codex 0.162 (every dir from
|
||||
* the git root down to cwd), gemini (cwd only, behind folder trust, which `--skip-trust`
|
||||
* already answers), opencode 1.3 (cwd up to the worktree), agy 1.3.2 (its `.agents/`
|
||||
* customization root), grok 1.0.5 (each tier from cwd up to the repo root), dsh 0.1.1
|
||||
* (the git root, else cwd) and omp (cwd up to the git root). Claude Code does NOT read
|
||||
* `.agents/skills`, so a case shared with a claude session lists each skill once there.
|
||||
*/
|
||||
const AGENTS_DIR_SKILL: Pick<CliEntry['capabilities'], 'agentSkillInjection' | 'agentSkillDir'> = {
|
||||
agentSkillInjection: true,
|
||||
agentSkillDir: '.agents/skills',
|
||||
};
|
||||
|
||||
// `accent` on every entry below (except SHELL, which the frontend renders no
|
||||
// distinct color for) is measured from the actual `.btn-toolbar.btn-run.mode-<id>`
|
||||
// CSS rule's `border-color` on the OG skin (styles.css) — the single cleanest
|
||||
@@ -293,7 +278,6 @@ const CLAUDE: CliEntry = {
|
||||
respawn: true,
|
||||
effort: true,
|
||||
agentSkillInjection: true,
|
||||
agentSkillDir: '.claude/skills',
|
||||
statusLineTelemetry: true,
|
||||
model: { source: 'claude-settings-file' },
|
||||
privilegedParams: [],
|
||||
@@ -510,7 +494,6 @@ const OPENCODE: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...AGENTS_DIR_SKILL,
|
||||
altScreen: 'strip-mux-and-mouse',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
|
||||
// Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real
|
||||
@@ -644,7 +627,6 @@ const CODEX: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...AGENTS_DIR_SKILL,
|
||||
// Codex draws `› Ask Codex to do anything` on its composer row and
|
||||
// `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no
|
||||
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
|
||||
@@ -834,7 +816,6 @@ const GEMINI: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...AGENTS_DIR_SKILL,
|
||||
altScreen: 'strip-full',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
// Measured on a live Gemini CLI 0.63.0 pane (capture-pane every 300 ms through real
|
||||
@@ -943,7 +924,6 @@ const ANTIGRAVITY: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...AGENTS_DIR_SKILL,
|
||||
altScreen: 'strip-mux-only',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
|
||||
@@ -1039,11 +1019,6 @@ const PI: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
// No agent skill (agentSkillInjection stays false): any project-level `.agents/skills`
|
||||
// or `.pi/skills` makes pi ask "Trust project folder?" at startup
|
||||
// (hasTrustRequiringProjectResources, pi 1.1.0), which would stall a spawned worker on
|
||||
// a dialog. pi reads the user-level `~/.agents/skills` without that prompt, so a pi
|
||||
// user installs it there (`npx skills add Ark0N/Codeman --skill codeman -g`).
|
||||
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
// Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn,
|
||||
@@ -1194,7 +1169,6 @@ const GROK: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...AGENTS_DIR_SKILL,
|
||||
// Fullscreen alt-screen TUI with mouse support, same strip as antigravity until measured
|
||||
// (opencode's mouse strip is #443): only the tmux-attach-time smcup strip, not Ink's full
|
||||
// erase-scrollback+DECSET strip.
|
||||
@@ -1367,7 +1341,6 @@ const DEEPSEEK: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...AGENTS_DIR_SKILL,
|
||||
// Definitive rather than inferred: the harness TUI reports idle/working/blocked to a
|
||||
// supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because
|
||||
// the session can disarm the bridge, and docker/remote cannot reach it at all.
|
||||
@@ -1533,7 +1506,6 @@ const OMP: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...AGENTS_DIR_SKILL,
|
||||
// Fullscreen alt-screen TUI, same shape as antigravity/grok: only the
|
||||
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
|
||||
altScreen: 'strip-mux-only',
|
||||
|
||||
@@ -69,14 +69,6 @@ export type Cond =
|
||||
*/
|
||||
export type QuoteStyle = 'auto' | 'bare' | 'double' | 'single';
|
||||
|
||||
/**
|
||||
* Workspace-relative directories an agent CLI scans for project skills. A closed list
|
||||
* rather than a free path: the value decides where the server WRITES, and
|
||||
* `~/.codeman/clis.json` can set it, so it must never be able to name `..` or an
|
||||
* absolute path.
|
||||
*/
|
||||
export type AgentSkillDir = '.claude/skills' | '.agents/skills';
|
||||
|
||||
/** One argv element. */
|
||||
export type ArgSpec =
|
||||
/** A bare literal word, e.g. the base binary or codex's `resume` subcommand. */
|
||||
@@ -523,20 +515,7 @@ export interface CliCapabilities {
|
||||
ralph: boolean;
|
||||
respawn: boolean;
|
||||
effort: boolean;
|
||||
/**
|
||||
* Codeman may write its agent skill (`skills/codeman`) into this CLI's workspace and
|
||||
* seed its per-session preamble (behind the synced `agentSkillEnabled` setting,
|
||||
* add-only, marker-owned). Where it lands comes from `agentSkillDir` below.
|
||||
*/
|
||||
agentSkillInjection: boolean;
|
||||
/**
|
||||
* The workspace-relative directory this CLI scans for project skills, so the skill
|
||||
* lands at `<workspace>/<agentSkillDir>/codeman/SKILL.md` where the CLI discovers it.
|
||||
* Measured per CLI. Absent with `agentSkillInjection: true` means Claude Code's
|
||||
* `.claude/skills`, which is what the boolean meant before this field existed, so a
|
||||
* `~/.codeman/clis.json` written against the old schema keeps its behaviour.
|
||||
*/
|
||||
agentSkillDir?: AgentSkillDir;
|
||||
statusLineTelemetry: boolean;
|
||||
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
|
||||
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
|
||||
|
||||
+20
-54
@@ -36,7 +36,6 @@ import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import type { HookEventType } from './types.js';
|
||||
import type { AgentSkillDir, CliCapabilities } from './config/cli-registry/types.js';
|
||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
|
||||
@@ -1229,8 +1228,8 @@ interface AgentSkillFile {
|
||||
*/
|
||||
async function readAgentSkillSource(): Promise<AgentSkillFile[]> {
|
||||
const src = agentSkillSourceDir();
|
||||
const skillMd = await readFile(join(src, 'SKILL.md'), 'utf-8');
|
||||
const files: AgentSkillFile[] = [{ relPath: 'SKILL.md', content: `${skillMd.trimEnd()}\n\n${AGENT_SKILL_MARKER}\n` }];
|
||||
const skill = await readFile(join(src, 'SKILL.md'), 'utf-8');
|
||||
const files: AgentSkillFile[] = [{ relPath: 'SKILL.md', content: `${skill.trimEnd()}\n\n${AGENT_SKILL_MARKER}\n` }];
|
||||
let referenceNames: string[] = [];
|
||||
try {
|
||||
referenceNames = (await readdir(join(src, 'reference'))).filter((name) => name.endsWith('.md')).sort();
|
||||
@@ -1286,8 +1285,7 @@ export type AgentSkillApplyResult =
|
||||
|
||||
/**
|
||||
* Install or refresh the Codeman agent skill into `skillDir` (a `.../codeman`
|
||||
* directory, e.g. `<case>/.claude/skills/codeman`, `~/.claude/skills/codeman` or
|
||||
* `<case>/.agents/skills/codeman`).
|
||||
* directory, e.g. `<case>/.claude/skills/codeman` or `~/.claude/skills/codeman`).
|
||||
*
|
||||
* Refuses two shapes rather than writing through them:
|
||||
* - a SYMLINK at the skill dir or its `skills/` parent: this repo's own dogfooding
|
||||
@@ -1338,20 +1336,16 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a session's agent preamble file (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`,
|
||||
* Seed a claude session's agent preamble file (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`,
|
||||
* default `~/.cache/`) from the packaged `skills/codeman/preamble.sh`, so the agent
|
||||
* skill's §0 bootstrap collapses to a two-line loader instead of a ~150-line block the
|
||||
* model has to type out (measured live: that paste alone cost a spawn run ~47 s of
|
||||
* generation time). The path formula must match the skill's
|
||||
* `${XDG_CACHE_HOME:-$HOME/.cache}` exactly; sessions inherit the server's env, so
|
||||
* reading the server's own XDG_CACHE_HOME keeps the two in agreement (`||` mirrors the
|
||||
* shell's `:-`, treating empty as unset). Callers gate to LOCAL sessions whose CLI gets
|
||||
* the skill (`agentSkillDir()` non-null, any CLI: the preamble is bash + curl + jq, nothing
|
||||
* claude-specific; a remote or in-container HOME is not this filesystem) and treat it as
|
||||
* best-effort: the skill's §0 fallback block self-heals a missing or stale file. Codex
|
||||
* caveat: codex 0.162 runs tool commands in a shared app-server daemon whose
|
||||
* `CODEMAN_SESSION_ID` is the starting session's, so its loader looks for that session's
|
||||
* file and its self id is wrong; the skill tells the agent how to notice.
|
||||
* shell's `:-`, treating empty as unset). Callers gate to LOCAL claude sessions (a
|
||||
* remote or in-container HOME is not this filesystem) and treat it as best-effort: the
|
||||
* skill's §0 fallback block self-heals a missing or stale file.
|
||||
*/
|
||||
export async function seedAgentSessionPreamble(sessionId: string): Promise<void> {
|
||||
const content = await readFile(join(agentSkillSourceDir(), 'preamble.sh'), 'utf-8');
|
||||
@@ -1484,59 +1478,31 @@ export async function removeAgentSkillFrom(skillDir: string): Promise<AgentSkill
|
||||
}
|
||||
await rmdir(join(skillDir, 'reference')).catch(() => {}); // fails when non-empty, fine
|
||||
await rmdir(skillDir).catch(() => {});
|
||||
await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` (or the CLI's own dir) if now empty
|
||||
await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` if now empty
|
||||
return 'removed';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code's project skills directory, and the meaning `agentSkillInjection: true`
|
||||
* had before `capabilities.agentSkillDir` existed.
|
||||
*/
|
||||
export const CLAUDE_AGENT_SKILL_DIR: AgentSkillDir = '.claude/skills';
|
||||
|
||||
/**
|
||||
* The workspace-relative skills directory this CLI scans, i.e. where its sessions get
|
||||
* the skill, or `null` when they get none. Pure registry data, so no call site
|
||||
* branches on a CLI id.
|
||||
*/
|
||||
export function agentSkillDir(
|
||||
capabilities: Pick<CliCapabilities, 'agentSkillInjection' | 'agentSkillDir'> | undefined
|
||||
): AgentSkillDir | null {
|
||||
if (!capabilities?.agentSkillInjection) return null;
|
||||
return capabilities.agentSkillDir ?? CLAUDE_AGENT_SKILL_DIR;
|
||||
}
|
||||
|
||||
/** `<casePath>/<dir>/codeman`, the directory the skill's SKILL.md lives in. */
|
||||
export function agentSkillDirFor(casePath: string, dir: AgentSkillDir): string {
|
||||
return join(casePath, dir, 'codeman');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or remove the Codeman agent skill in `<case>/<dir>/codeman`, where `dir` is the
|
||||
* skills directory the session's CLI scans (default: Claude Code's `.claude/skills`;
|
||||
* callers resolve it with `agentSkillDir()`), mirroring `applyStatusLineConfig`'s
|
||||
* shape. Gated by the synced `agentSkillEnabled` app setting (default OFF).
|
||||
* Add or remove the Codeman agent skill in `<case>/.claude/skills/codeman`,
|
||||
* mirroring `applyStatusLineConfig`'s shape. Gated by the synced `agentSkillEnabled`
|
||||
* app setting (default OFF); callers gate on Claude mode, since the skill is discovered
|
||||
* via `.claude/skills/`, which only Claude Code reads.
|
||||
*
|
||||
* Call-site policy is ADD-ONLY on session create (callers pass `enabled: true` or
|
||||
* skip the call), for the statusLine reason: sessions in a repo share one workspace,
|
||||
* so a single create while the setting is off must not yank the skill out from
|
||||
* skip the call), for the statusLine reason: sessions in a repo share one `.claude/`
|
||||
* dir, so a single create while the setting is off must not yank the skill out from
|
||||
* under other live sessions.
|
||||
*
|
||||
* ⚠️ Consequence: turning `agentSkillEnabled` OFF sweeps nothing. There is deliberately
|
||||
* no server-side toggle-off sweep (it would have to walk every case, including ones
|
||||
* with live sessions, and would hit exactly the shared-workspace hazard above), so
|
||||
* with live sessions, and would hit exactly the shared-`.claude/` hazard above), so
|
||||
* already-injected copies stay on disk until removed per case with
|
||||
* `codeman skill uninstall --case <name>`, which removes the Codeman-managed copy from
|
||||
* every skills directory the registry could have written. The `enabled: false` branch
|
||||
* here backs that CLI and the tests; it has no server call site. Keep the README's
|
||||
* Agent Skill note in sync if this ever changes.
|
||||
* `codeman skill uninstall --case <name>`. The `enabled: false` branch here backs that
|
||||
* CLI and the tests; it has no server call site. Keep the README's Agent Skill note in
|
||||
* sync if this ever changes.
|
||||
*/
|
||||
export async function applyAgentSkill(
|
||||
casePath: string,
|
||||
enabled: boolean,
|
||||
dir: AgentSkillDir = CLAUDE_AGENT_SKILL_DIR
|
||||
): Promise<AgentSkillApplyResult> {
|
||||
const skillDir = agentSkillDirFor(casePath, dir);
|
||||
export async function applyAgentSkill(casePath: string, enabled: boolean): Promise<AgentSkillApplyResult> {
|
||||
const skillDir = join(casePath, '.claude', 'skills', 'codeman');
|
||||
return enabled ? installAgentSkillInto(skillDir) : removeAgentSkillFrom(skillDir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* @fileoverview Holds the OS sleep lock behind `keepAwakeEnabled` for exactly as long
|
||||
* as Codeman runs. The decisions (which lock, when, what a failure means) are pure and
|
||||
* live in `keep-awake.ts`; this module only does the IO and keeps the state current.
|
||||
*
|
||||
* Lifecycle rules, each load-bearing:
|
||||
*
|
||||
* - **The lock dies with the server, never after it.** Linux: `systemd-inhibit` runs a
|
||||
* `cat` on a stdin pipe from this process, so any exit (SIGKILL included) closes the
|
||||
* pipe and releases the lock even under the unit's `KillMode=process`. macOS:
|
||||
* `caffeinate -w <pid>` exits with the server. The macOS lid request file is only a
|
||||
* heartbeat: the root helper ignores it once it is two minutes old.
|
||||
* - **Reconcile from settings, never from a request body.** `apply()` takes the merged
|
||||
* config and is idempotent, so the boot path and every `PUT /api/settings` call the
|
||||
* same thing.
|
||||
* - **A refusal is a state, not an error.** polkit denies the Linux lock until someone
|
||||
* logs in to the desktop; that is reported as `denied` and retried, never thrown.
|
||||
* - **Inert under vitest** unless a test injects its own deps, so the suite never takes
|
||||
* a real lock on the machine running it.
|
||||
*/
|
||||
|
||||
import { spawn as nodeSpawn, execFile, type ChildProcess } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { release } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import {
|
||||
MAC_LID_HEARTBEAT_MS,
|
||||
MAC_LID_HELPER_PLIST,
|
||||
MAC_LID_REQUEST_FILE,
|
||||
LINUX_HELD_MARKER,
|
||||
POWER_POLL_MS,
|
||||
RETRY_MS,
|
||||
classifyInhibitFailure,
|
||||
isOnAcPowerLinux,
|
||||
isOnAcPowerMac,
|
||||
keepAwakePlatform,
|
||||
linuxInhibitArgs,
|
||||
macCaffeinateArgs,
|
||||
shouldHoldLock,
|
||||
type KeepAwakeConfig,
|
||||
type KeepAwakePlatform,
|
||||
type KeepAwakeStatus,
|
||||
type PowerSupplyInfo,
|
||||
} from './keep-awake.js';
|
||||
|
||||
/** Grace between closing the inhibitor's stdin and SIGTERM. */
|
||||
const RELEASE_GRACE_MS = 2_000;
|
||||
/** Bound on captured stderr from the lock process. */
|
||||
const STDERR_CAP = 2_048;
|
||||
|
||||
export interface KeepAwakeDeps {
|
||||
platform: KeepAwakePlatform;
|
||||
serverPid: number;
|
||||
spawn: (command: string, args: string[]) => ChildProcess;
|
||||
readPowerSupplies: () => Promise<PowerSupplyInfo[]>;
|
||||
readMacBatt: () => Promise<string | null>;
|
||||
lidHelperInstalled: () => Promise<boolean>;
|
||||
writeLidRequest: (pid: number) => Promise<void>;
|
||||
removeLidRequest: () => Promise<void>;
|
||||
}
|
||||
|
||||
const POWER_SUPPLY_DIR = '/sys/class/power_supply';
|
||||
|
||||
async function readSysValue(dir: string, name: string): Promise<string | undefined> {
|
||||
try {
|
||||
return (await fs.readFile(join(dir, name), 'utf-8')).trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readLinuxPowerSupplies(): Promise<PowerSupplyInfo[]> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(POWER_SUPPLY_DIR);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: PowerSupplyInfo[] = [];
|
||||
for (const name of names) {
|
||||
const dir = join(POWER_SUPPLY_DIR, name);
|
||||
const type = await readSysValue(dir, 'type');
|
||||
if (!type) continue;
|
||||
const online = await readSysValue(dir, 'online');
|
||||
out.push({
|
||||
type,
|
||||
online: online === undefined ? undefined : online === '1',
|
||||
status: await readSysValue(dir, 'status'),
|
||||
scope: await readSysValue(dir, 'scope'),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readPmsetBatt(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
execFile('/usr/bin/pmset', ['-g', 'batt'], { timeout: 5_000 }, (err, stdout) => {
|
||||
resolve(err ? null : String(stdout));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function defaultDeps(): KeepAwakeDeps {
|
||||
const lidRequest = () => dataPath(MAC_LID_REQUEST_FILE);
|
||||
return {
|
||||
platform: keepAwakePlatform(process.platform, release()),
|
||||
serverPid: process.pid,
|
||||
spawn: (command, args) => nodeSpawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] }),
|
||||
readPowerSupplies: readLinuxPowerSupplies,
|
||||
readMacBatt: readPmsetBatt,
|
||||
lidHelperInstalled: async () => {
|
||||
try {
|
||||
await fs.access(MAC_LID_HELPER_PLIST);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
writeLidRequest: async (pid) => {
|
||||
await fs.writeFile(lidRequest(), `${pid}\n`, { mode: 0o644 });
|
||||
},
|
||||
removeLidRequest: async () => {
|
||||
await fs.rm(lidRequest(), { force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class KeepAwakeManager {
|
||||
private readonly deps: KeepAwakeDeps;
|
||||
private readonly inert: boolean;
|
||||
private config: KeepAwakeConfig = { enabled: false, acOnly: true };
|
||||
private status: KeepAwakeStatus;
|
||||
private child: ChildProcess | null = null;
|
||||
/** Children we asked to exit; their exit is expected and must not trigger a retry. */
|
||||
private releasing = new WeakSet<ChildProcess>();
|
||||
private powerTimer: NodeJS.Timeout | null = null;
|
||||
private retryTimer: NodeJS.Timeout | null = null;
|
||||
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
/** Serializes reconciles: a settings PUT can land while a power poll is mid-read. */
|
||||
private chain: Promise<void> = Promise.resolve();
|
||||
private stopped = false;
|
||||
private lastLoggedState: KeepAwakeStatus['state'] = 'off';
|
||||
|
||||
constructor(deps?: KeepAwakeDeps) {
|
||||
this.inert = !deps && !!process.env.VITEST;
|
||||
this.deps = deps ?? defaultDeps();
|
||||
this.status = {
|
||||
enabled: false,
|
||||
acOnly: true,
|
||||
platform: this.deps.platform,
|
||||
state: 'off',
|
||||
onAc: null,
|
||||
lidHelper: null,
|
||||
detail: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Current status (a copy). */
|
||||
getStatus(): KeepAwakeStatus {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
/** Reconcile to `config`. Idempotent; safe to call on every settings save. */
|
||||
apply(config: KeepAwakeConfig): Promise<void> {
|
||||
this.config = { ...config };
|
||||
this.stopped = false;
|
||||
return this.enqueue();
|
||||
}
|
||||
|
||||
/** Release everything and stop all timers (server shutdown). */
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
this.clearTimers();
|
||||
await this.chain.catch(() => {});
|
||||
await this.release();
|
||||
this.status = { ...this.status, state: 'off', detail: null };
|
||||
}
|
||||
|
||||
private enqueue(): Promise<void> {
|
||||
const next = this.chain
|
||||
.then(() => this.reconcile())
|
||||
.catch((err) => {
|
||||
console.error('[keep-awake] reconcile failed:', err);
|
||||
});
|
||||
this.chain = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
private async reconcile(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
const { enabled, acOnly } = this.config;
|
||||
this.status = { ...this.status, enabled, acOnly };
|
||||
|
||||
if (!enabled) {
|
||||
this.clearTimers();
|
||||
await this.release();
|
||||
this.setState('off', null);
|
||||
return;
|
||||
}
|
||||
if (this.inert) {
|
||||
this.setState('unavailable', 'Disabled under the test runner.');
|
||||
return;
|
||||
}
|
||||
if (this.deps.platform === 'unsupported') {
|
||||
this.setState('unavailable', 'Not supported on this system.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.deps.platform === 'macos') {
|
||||
this.status.lidHelper = (await this.deps.lidHelperInstalled()) ? 'installed' : 'missing';
|
||||
}
|
||||
|
||||
const onAc = acOnly ? await this.readOnAc() : null;
|
||||
this.status.onAc = onAc;
|
||||
this.ensurePowerPoll(acOnly);
|
||||
|
||||
if (!shouldHoldLock(this.config, onAc)) {
|
||||
await this.release();
|
||||
this.setState('paused-battery', null);
|
||||
return;
|
||||
}
|
||||
|
||||
// The lid helper is a separate mechanism from caffeinate: it only needs a fresh
|
||||
// request file, so it runs whether or not caffeinate is currently up.
|
||||
if (this.deps.platform === 'macos' && this.status.lidHelper === 'installed') this.startLidHeartbeat();
|
||||
|
||||
// A pending retry owns the next attempt; a live child is already the lock.
|
||||
if (this.child || this.retryTimer) return;
|
||||
if (this.status.state === 'unavailable') return;
|
||||
this.acquire();
|
||||
}
|
||||
|
||||
private async readOnAc(): Promise<boolean | null> {
|
||||
try {
|
||||
if (this.deps.platform === 'linux') return isOnAcPowerLinux(await this.deps.readPowerSupplies());
|
||||
if (this.deps.platform === 'macos') {
|
||||
const out = await this.deps.readMacBatt();
|
||||
return out === null ? null : isOnAcPowerMac(out);
|
||||
}
|
||||
} catch {
|
||||
/* unknown */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private acquire(): void {
|
||||
if (this.deps.platform === 'linux') this.acquireLinux();
|
||||
else if (this.deps.platform === 'macos') this.acquireMac();
|
||||
}
|
||||
|
||||
private acquireLinux(): void {
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = this.deps.spawn('systemd-inhibit', linuxInhibitArgs());
|
||||
} catch (err) {
|
||||
this.onSpawnError(err as NodeJS.ErrnoException);
|
||||
return;
|
||||
}
|
||||
this.child = child;
|
||||
this.setState('starting', null);
|
||||
// Closing the pipe to an already-dead process emits EPIPE on the stream; unhandled,
|
||||
// that would take the whole server down.
|
||||
child.stdin?.on('error', () => {});
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
if (this.child === child && String(chunk).includes(LINUX_HELD_MARKER)) this.setState('active', null);
|
||||
});
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
if (stderr.length < STDERR_CAP) stderr += String(chunk);
|
||||
});
|
||||
child.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (this.child !== child) return;
|
||||
this.child = null;
|
||||
this.onSpawnError(err);
|
||||
});
|
||||
child.on('exit', () => {
|
||||
if (this.releasing.has(child) || this.child !== child) return;
|
||||
this.child = null;
|
||||
const { state, detail } = classifyInhibitFailure(stderr);
|
||||
this.setState(state, detail);
|
||||
if (state !== 'unavailable') this.scheduleRetry();
|
||||
});
|
||||
}
|
||||
|
||||
private acquireMac(): void {
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = this.deps.spawn('/usr/bin/caffeinate', macCaffeinateArgs(this.deps.serverPid));
|
||||
} catch (err) {
|
||||
this.onSpawnError(err as NodeJS.ErrnoException);
|
||||
return;
|
||||
}
|
||||
this.child = child;
|
||||
this.setState('starting', null);
|
||||
child.on('spawn', () => {
|
||||
if (this.child === child) this.setState('active', null);
|
||||
});
|
||||
child.stdin?.on('error', () => {});
|
||||
child.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (this.child !== child) return;
|
||||
this.child = null;
|
||||
this.onSpawnError(err);
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (this.releasing.has(child) || this.child !== child) return;
|
||||
this.child = null;
|
||||
this.setState('failed', `caffeinate exited (${signal ?? `code ${code}`})`);
|
||||
this.scheduleRetry();
|
||||
});
|
||||
}
|
||||
|
||||
private onSpawnError(err: NodeJS.ErrnoException): void {
|
||||
if (err.code === 'ENOENT') {
|
||||
const tool = this.deps.platform === 'macos' ? 'caffeinate' : 'systemd-inhibit';
|
||||
this.setState('unavailable', `${tool} is not installed on this machine.`);
|
||||
return;
|
||||
}
|
||||
this.setState('failed', err.message);
|
||||
this.scheduleRetry();
|
||||
}
|
||||
|
||||
private startLidHeartbeat(): void {
|
||||
if (this.heartbeatTimer) return;
|
||||
const beat = () => {
|
||||
void this.deps.writeLidRequest(this.deps.serverPid).catch((err) => {
|
||||
console.warn('[keep-awake] could not write the lid request file:', err);
|
||||
});
|
||||
};
|
||||
beat();
|
||||
this.heartbeatTimer = setInterval(beat, MAC_LID_HEARTBEAT_MS);
|
||||
this.heartbeatTimer.unref?.();
|
||||
}
|
||||
|
||||
private async release(): Promise<void> {
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
if (this.deps.platform === 'macos' && !this.inert) {
|
||||
await this.deps.removeLidRequest().catch(() => {});
|
||||
}
|
||||
const child = this.child;
|
||||
if (!child) return;
|
||||
this.child = null;
|
||||
this.releasing.add(child);
|
||||
// Closing stdin ends `cat` (Linux), which ends systemd-inhibit and drops the lock.
|
||||
child.stdin?.end();
|
||||
if (this.deps.platform === 'macos') {
|
||||
child.kill('SIGTERM');
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
|
||||
}, RELEASE_GRACE_MS);
|
||||
timer.unref?.();
|
||||
child.once('exit', () => clearTimeout(timer));
|
||||
}
|
||||
|
||||
private scheduleRetry(): void {
|
||||
if (this.retryTimer || this.stopped) return;
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
void this.enqueue();
|
||||
}, RETRY_MS);
|
||||
this.retryTimer.unref?.();
|
||||
}
|
||||
|
||||
private ensurePowerPoll(acOnly: boolean): void {
|
||||
if (!acOnly) {
|
||||
if (this.powerTimer) clearInterval(this.powerTimer);
|
||||
this.powerTimer = null;
|
||||
return;
|
||||
}
|
||||
if (this.powerTimer) return;
|
||||
this.powerTimer = setInterval(() => void this.enqueue(), POWER_POLL_MS);
|
||||
this.powerTimer.unref?.();
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
for (const t of [this.powerTimer, this.heartbeatTimer]) if (t) clearInterval(t);
|
||||
if (this.retryTimer) clearTimeout(this.retryTimer);
|
||||
this.powerTimer = this.heartbeatTimer = this.retryTimer = null;
|
||||
}
|
||||
|
||||
private setState(state: KeepAwakeStatus['state'], detail: string | null): void {
|
||||
// Log settled transitions only: a denied lock is retried every minute and would
|
||||
// otherwise log `starting` + `denied` forever on a box nobody logs in to.
|
||||
if (state !== 'starting' && state !== this.lastLoggedState) {
|
||||
this.lastLoggedState = state;
|
||||
console.log(`[keep-awake] ${state}${detail ? `: ${detail}` : ''}`);
|
||||
}
|
||||
this.status = { ...this.status, state, detail };
|
||||
}
|
||||
}
|
||||
|
||||
/** The process-wide manager. */
|
||||
export const keepAwake = new KeepAwakeManager();
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* @fileoverview Pure decisions behind "Keep this computer awake while Codeman runs"
|
||||
* (`keepAwakeEnabled`, SYNCED, default OFF; `keepAwakeAcOnly`, default ON).
|
||||
*
|
||||
* A laptop that suspends freezes every agent session: nothing runs while the lid is
|
||||
* closed, the phone loses its tailnet route to the dashboard, and in-flight API
|
||||
* requests and ssh links usually break. The IO side (`keep-awake-manager.ts`) holds an
|
||||
* OS-level sleep lock for exactly as long as Codeman runs; everything it has to decide
|
||||
* lives here so the tests exercise the shipped logic.
|
||||
*
|
||||
* What each platform's lock can and cannot do, measured rather than assumed:
|
||||
*
|
||||
* - **Linux (systemd-logind).** Lid-close suspend obeys only the LOW-level
|
||||
* `handle-lid-switch` lock: logind's default `LidSwitchIgnoreInhibited=yes` makes it
|
||||
* ignore a plain `sleep` lock for the lid. A `sleep` lock also never blocks a suspend
|
||||
* requested by the SAME user (logind skips inhibitors whose uid matches the caller),
|
||||
* so the desktop's own Automatic Suspend timer keeps working; it is the user's setting
|
||||
* to change. polkit grants `handle-lid-switch` and `sleep` only to a process in an
|
||||
* active login session, and a systemd user service is not in one: polkit then falls
|
||||
* back to the user's display session, so the lock is granted while someone is logged
|
||||
* in to the desktop and DENIED at a login screen (or on a headless box). The manager
|
||||
* retries a denial, so logging in later picks it up.
|
||||
* - **macOS.** `caffeinate -i -s` (no root) blocks idle sleep, and system sleep while on
|
||||
* AC power, but NOT lid-close sleep. Only `pmset -a disablesleep 1` keeps a closed
|
||||
* MacBook awake, and that is a machine-wide root setting with no owner process, so the
|
||||
* optional root helper (`scripts/keep-awake-macos.sh`, installed by `install.sh`)
|
||||
* applies it while the server keeps a fresh request file, and undoes it when the file
|
||||
* goes stale or disappears.
|
||||
*/
|
||||
|
||||
/** Settings keys this feature reads. */
|
||||
export interface KeepAwakeConfig {
|
||||
/** Hold a sleep lock while Codeman runs. Opt-in: only an explicit `true` enables. */
|
||||
enabled: boolean;
|
||||
/** Release the lock while the machine runs on battery. Default ON. */
|
||||
acOnly: boolean;
|
||||
}
|
||||
|
||||
export type KeepAwakePlatform = 'linux' | 'macos' | 'unsupported';
|
||||
|
||||
/**
|
||||
* - `off`: the setting is off.
|
||||
* - `paused-battery`: `acOnly` and the machine is on battery.
|
||||
* - `starting`: a lock was requested and has not been confirmed yet.
|
||||
* - `active`: the lock is held.
|
||||
* - `denied`: Linux refused the lock (no active desktop login); retried periodically.
|
||||
* - `unavailable`: this machine has no usable mechanism (no systemd-logind, WSL, …).
|
||||
* - `failed`: anything else; retried periodically.
|
||||
*/
|
||||
export type KeepAwakeState = 'off' | 'paused-battery' | 'starting' | 'active' | 'denied' | 'unavailable' | 'failed';
|
||||
|
||||
export interface KeepAwakeStatus {
|
||||
enabled: boolean;
|
||||
acOnly: boolean;
|
||||
platform: KeepAwakePlatform;
|
||||
state: KeepAwakeState;
|
||||
/** Last known power source: true = AC, false = battery, null = unknown or not read. */
|
||||
onAc: boolean | null;
|
||||
/** macOS only: whether the root lid-close helper is installed. null elsewhere. */
|
||||
lidHelper: 'installed' | 'missing' | null;
|
||||
/** Short reason for `denied` / `unavailable` / `failed`, else null. */
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
/** One entry of `/sys/class/power_supply/<name>/`. */
|
||||
export interface PowerSupplyInfo {
|
||||
/** `type`: Mains, Battery, USB, UPS, Wireless, … */
|
||||
type: string;
|
||||
/** `online` for adapters (1/0); undefined when the file is absent. */
|
||||
online?: boolean;
|
||||
/** `status` for batteries: Charging, Discharging, Full, Not charging, Unknown. */
|
||||
status?: string;
|
||||
/** `scope`: `Device` marks a peripheral's battery (a mouse), not the machine's. */
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/** Lock set requested from logind. Order is cosmetic; all three are block locks. */
|
||||
export const LINUX_INHIBIT_WHAT = 'handle-lid-switch:sleep:idle';
|
||||
|
||||
/** Printed by the inhibitor's child once the lock is held (systemd-inhibit execs it only then). */
|
||||
export const LINUX_HELD_MARKER = 'codeman-keep-awake-held';
|
||||
|
||||
/** Name of the request file the macOS lid helper watches, under the data dir. */
|
||||
export const MAC_LID_REQUEST_FILE = 'keep-awake-lid.pid';
|
||||
|
||||
/** The LaunchDaemon the installer writes for the macOS lid helper. */
|
||||
export const MAC_LID_HELPER_PLIST = '/Library/LaunchDaemons/com.codeman.keepawake.plist';
|
||||
|
||||
/** How often the server refreshes the macOS request file. The helper treats it as stale after 2 minutes. */
|
||||
export const MAC_LID_HEARTBEAT_MS = 30_000;
|
||||
|
||||
/** How often the power source is re-read while `acOnly` is on. */
|
||||
export const POWER_POLL_MS = 30_000;
|
||||
|
||||
/** Delay before retrying a denied or failed lock. */
|
||||
export const RETRY_MS = 60_000;
|
||||
|
||||
/** Settings → config. Absent `keepAwakeEnabled` is OFF; absent `keepAwakeAcOnly` is ON. */
|
||||
export function resolveKeepAwakeConfig(settings: Record<string, unknown>): KeepAwakeConfig {
|
||||
return {
|
||||
enabled: settings.keepAwakeEnabled === true,
|
||||
acOnly: settings.keepAwakeAcOnly !== false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which mechanism applies. WSL is unsupported even though it may have systemd: the
|
||||
* Windows host decides when the machine sleeps, and a lock inside the VM does nothing.
|
||||
*/
|
||||
export function keepAwakePlatform(platform: string, kernelRelease: string): KeepAwakePlatform {
|
||||
if (platform === 'darwin') return 'macos';
|
||||
if (platform === 'linux') return /microsoft/i.test(kernelRelease) ? 'unsupported' : 'linux';
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
/**
|
||||
* Linux power source from `/sys/class/power_supply`. Peripheral batteries
|
||||
* (`scope=Device`) are ignored. A machine with no battery of its own runs on external
|
||||
* power by definition; otherwise any online adapter means AC, adapters that are all
|
||||
* offline mean battery, and a machine that lists no adapter at all falls back to the
|
||||
* battery's own charging status. null when nothing conclusive is reported.
|
||||
*/
|
||||
export function isOnAcPowerLinux(supplies: readonly PowerSupplyInfo[]): boolean | null {
|
||||
const system = supplies.filter((s) => (s.scope ?? '').toLowerCase() !== 'device');
|
||||
const batteries = system.filter((s) => s.type === 'Battery');
|
||||
if (batteries.length === 0) return true;
|
||||
const adapters = system.filter((s) => s.type !== 'Battery' && s.online !== undefined);
|
||||
if (adapters.some((s) => s.online)) return true;
|
||||
if (adapters.length > 0) return false;
|
||||
const statuses = batteries.map((b) => (b.status ?? '').toLowerCase());
|
||||
if (statuses.includes('discharging')) return false;
|
||||
if (statuses.some((s) => s === 'charging' || s === 'full' || s === 'not charging')) return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** macOS power source from `pmset -g batt` (first line names the source). */
|
||||
export function isOnAcPowerMac(pmsetBatt: string): boolean | null {
|
||||
const m = /drawing from '([^']+)'/.exec(pmsetBatt);
|
||||
if (!m) return null;
|
||||
if (m[1] === 'AC Power') return true;
|
||||
if (m[1] === 'Battery Power' || m[1] === 'UPS Power') return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the lock should be held right now. An unknown power source counts as AC:
|
||||
* it is what a desktop or VM without battery reporting looks like.
|
||||
*/
|
||||
export function shouldHoldLock(config: KeepAwakeConfig, onAc: boolean | null): boolean {
|
||||
if (!config.enabled) return false;
|
||||
if (!config.acOnly) return true;
|
||||
return onAc !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* argv for `systemd-inhibit`. The child it runs announces the lock and then becomes
|
||||
* `cat` on a stdin pipe from the server: when the server exits for ANY reason (a
|
||||
* SIGKILL included) the pipe closes, `cat` exits, and logind drops the lock. That
|
||||
* matters because the shipped unit uses `KillMode=process`, which leaves children
|
||||
* running on stop; a `sleep infinity` child would hold the lock forever.
|
||||
*/
|
||||
export function linuxInhibitArgs(): string[] {
|
||||
return [
|
||||
`--what=${LINUX_INHIBIT_WHAT}`,
|
||||
'--who=Codeman',
|
||||
'--why=Keeping agent sessions running',
|
||||
'--mode=block',
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
`echo ${LINUX_HELD_MARKER}; exec cat`,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* argv for `caffeinate`: `-i` idle sleep, `-s` system sleep on AC power. `-w` ties
|
||||
* the assertion to the server's pid, so it ends when the server does, crash included.
|
||||
*/
|
||||
export function macCaffeinateArgs(serverPid: number): string[] {
|
||||
return ['-i', '-s', '-w', String(serverPid)];
|
||||
}
|
||||
|
||||
/** Classify why `systemd-inhibit` exited before the lock was confirmed. */
|
||||
export function classifyInhibitFailure(stderr: string): { state: 'denied' | 'unavailable' | 'failed'; detail: string } {
|
||||
const text = stderr.trim();
|
||||
if (/access denied|not authori[sz]ed|interactive authentication required/i.test(text)) {
|
||||
return {
|
||||
state: 'denied',
|
||||
detail: 'Linux refused the sleep lock: no one is logged in to a desktop session on this machine.',
|
||||
};
|
||||
}
|
||||
if (/failed to connect to (system )?bus|no such file or directory|not found|unknown (unit|object)/i.test(text)) {
|
||||
return { state: 'unavailable', detail: 'systemd-logind is not reachable on this machine.' };
|
||||
}
|
||||
const firstLine = text.split('\n')[0]?.slice(0, 200) || 'the inhibitor exited';
|
||||
return { state: 'failed', detail: firstLine };
|
||||
}
|
||||
+28
-13
@@ -2597,19 +2597,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="agentSkillGroup">
|
||||
<div class="set-group-head"><h4>All agent CLIs</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="agent skill codeman api workers codex gemini opencode pi grok deepseek omp antigravity">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Codeman Agent Skill</span>
|
||||
<span class="set-row-desc">Write Codeman's agent skill into each new session's folder, where that CLI looks for skills, so the agent in it can start workers in any installed CLI, send them prompts and wait for results. Every CLI except Pi and a plain shell.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Claude</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
@@ -2646,6 +2633,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentTeams"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="agent skill codeman api workers">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Codeman Agent Skill</span>
|
||||
<span class="set-row-desc">Let new sessions start workers, send prompts and wait for results over the API.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="workspace hooks alerts approvals notifications settings.local.json">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Workspace Hooks</span>
|
||||
@@ -3113,6 +3107,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="keepAwakeGroup">
|
||||
<div class="set-group-head"><h4>Power</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="keep awake sleep suspend standby lid close laptop notebook power caffeinate inhibit">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep this computer awake</span>
|
||||
<span class="set-row-desc">While Codeman runs, stop the machine it runs on from sleeping, including when a laptop's lid is closed, so agents keep working and stay reachable from your phone. The screen can still turn off.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsKeepAwake"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="keep awake battery ac power charger plugged in">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Only on AC power</span>
|
||||
<span class="set-row-desc">Sleep normally on battery. A closed laptop in a bag gets hot and drains fast.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsKeepAwakeAcOnly" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div id="keepAwakeStatus" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Remote access</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
|
||||
@@ -424,6 +424,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
|
||||
this.applyMcpSyncVisibility();
|
||||
this._applyDoctorAdminGate();
|
||||
// Keep awake: server state (an OS sleep lock), default OFF; "only on AC" default ON.
|
||||
document.getElementById('appSettingsKeepAwake').checked = settings.keepAwakeEnabled === true;
|
||||
document.getElementById('appSettingsKeepAwakeAcOnly').checked = settings.keepAwakeAcOnly !== false;
|
||||
this._applyKeepAwakeAdminGate();
|
||||
this.loadKeepAwakeStatus();
|
||||
this.loadWebhook();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
@@ -1224,6 +1229,53 @@ Object.assign(CodemanApp.prototype, {
|
||||
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Keep awake changes machine state, and PUT /api/settings drops a non-admin's value in
|
||||
* multi-user mode, so a non-admin gets no Power group at all rather than a switch that
|
||||
* silently does nothing. Also wired to `codeman:me` for the late-resolving role.
|
||||
*/
|
||||
_applyKeepAwakeAdminGate() {
|
||||
const group = document.getElementById('keepAwakeGroup');
|
||||
if (!group) return;
|
||||
const me = window.__codemanUser || {};
|
||||
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||
},
|
||||
|
||||
/** One line on what the sleep lock is doing right now (GET /api/system/keep-awake). */
|
||||
async loadKeepAwakeStatus() {
|
||||
const out = document.getElementById('keepAwakeStatus');
|
||||
if (!out) return;
|
||||
let s = null;
|
||||
try {
|
||||
const res = await this._api('/api/system/keep-awake');
|
||||
const body = res && res.ok ? await res.json() : null;
|
||||
s = body?.success ? body.data : null;
|
||||
} catch { /* leave hidden */ }
|
||||
const text = s ? this.describeKeepAwakeStatus(s) : '';
|
||||
out.textContent = text;
|
||||
out.style.display = text ? 'block' : 'none';
|
||||
},
|
||||
|
||||
/** Status → sentence. Pure; '' hides the note. */
|
||||
describeKeepAwakeStatus(s) {
|
||||
switch (s.state) {
|
||||
case 'off': return '';
|
||||
case 'starting': return 'Starting…';
|
||||
case 'paused-battery': return 'Paused: running on battery. It comes back when you plug in.';
|
||||
case 'unavailable': return `Not available here: ${s.detail || 'no supported sleep lock on this system.'}`;
|
||||
case 'denied': return `${s.detail || 'The sleep lock was refused.'} Codeman retries every minute, so it applies once you log in.`;
|
||||
case 'failed': return `Could not take the sleep lock (${s.detail || 'unknown error'}). Retrying every minute.`;
|
||||
case 'active':
|
||||
if (s.platform === 'macos') {
|
||||
return s.lidHelper === 'installed'
|
||||
? 'Active: this Mac will not sleep while Codeman runs, even with the lid closed. Apple menu > Sleep is blocked too.'
|
||||
: 'Active for idle sleep only: closing the lid still puts this Mac to sleep. To cover the lid, re-run the installer and answer yes to the lid question (asks for your admin password once).';
|
||||
}
|
||||
return "Active: closing the lid will not suspend this machine while Codeman runs. Your desktop's own Automatic Suspend timer is separate and still runs: if it is on, switch it off for when the machine is plugged in.";
|
||||
default: return '';
|
||||
}
|
||||
},
|
||||
|
||||
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
|
||||
async mcpSync(apply) {
|
||||
const out = this.$('mcpSyncResult');
|
||||
@@ -2633,6 +2685,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked,
|
||||
workspaceHooksEnabled: document.getElementById('appSettingsWorkspaceHooks').checked,
|
||||
keepAwakeEnabled: document.getElementById('appSettingsKeepAwake').checked,
|
||||
keepAwakeAcOnly: document.getElementById('appSettingsKeepAwakeAcOnly').checked,
|
||||
claudeVoiceEnabled: document.getElementById('appSettingsClaudeVoice').checked,
|
||||
claudeModel: document.getElementById('appSettingsClaudeModel').value,
|
||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||
@@ -4766,4 +4820,5 @@ document.addEventListener?.('codeman:me', () => {
|
||||
window.app?._applyCliManagementAdminGate?.();
|
||||
window.app?._applyMcpSyncAdminGate?.();
|
||||
window.app?._applyDoctorAdminGate?.();
|
||||
window.app?._applyKeepAwakeAdminGate?.();
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
|
||||
import { Session } from '../../session.js';
|
||||
import { resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import { agentSkillDir, applyWorkspaceHooks, seedAgentSessionPreamble } from '../../hooks-config.js';
|
||||
import { applyWorkspaceHooks, seedAgentSessionPreamble } from '../../hooks-config.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -247,7 +247,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
|
||||
// skill falls back to writing out the whole ~150-line §0 preamble. Remote and
|
||||
// docker sessions never reach here (the plan rejects them as
|
||||
// `remote-or-docker`), so the local-only condition is structural.
|
||||
if (agentSkillDir(getCli(session.mode)?.capabilities) && (await ctx.getAgentSkillEnabled())) {
|
||||
if (getCli(session.mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) {
|
||||
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
|
||||
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
|
||||
);
|
||||
|
||||
@@ -114,7 +114,7 @@ import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-mar
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
|
||||
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
||||
import type { AgentSkillDir, NewlineSequence } from '../../config/cli-registry/types.js';
|
||||
import type { NewlineSequence } from '../../config/cli-registry/types.js';
|
||||
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
||||
import { applyLaunchDefaults } from '../launch-defaults.js';
|
||||
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
||||
@@ -124,8 +124,6 @@ import {
|
||||
writeHooksConfig,
|
||||
updateCaseModel,
|
||||
stripCaseEnvKeys,
|
||||
agentSkillDir,
|
||||
agentSkillDirFor,
|
||||
applyAgentSkill,
|
||||
refreshUserAgentSkill,
|
||||
seedAgentSessionPreamble,
|
||||
@@ -794,8 +792,8 @@ function abortOnClientHangUp(reply: FastifyReply): AbortController {
|
||||
* Injection is best-effort and stays that way: neither a refusal nor a thrown error
|
||||
* may fail the create.
|
||||
*/
|
||||
async function injectAgentSkill(casePath: string, dir: AgentSkillDir): Promise<void> {
|
||||
const skillDir = agentSkillDirFor(casePath, dir);
|
||||
async function injectAgentSkill(casePath: string): Promise<void> {
|
||||
const skillDir = join(casePath, '.claude', 'skills', 'codeman');
|
||||
try {
|
||||
// Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`,
|
||||
// written once by `codeman skill install`) over the case copy injected below, so a
|
||||
@@ -804,7 +802,7 @@ async function injectAgentSkill(casePath: string, dir: AgentSkillDir): Promise<v
|
||||
// current on the same trigger. Refresh-only + marker-guarded; quiet on refusal,
|
||||
// since a foreign user copy is the user's own authored skill, not a config error.
|
||||
await refreshUserAgentSkill();
|
||||
const result = await applyAgentSkill(casePath, true, dir);
|
||||
const result = await applyAgentSkill(casePath, true);
|
||||
if (result === 'foreign') {
|
||||
console.warn(
|
||||
`[agent-skill] not injected: ${skillDir} exists but is not Codeman-managed (no marker), refusing to touch it. Remove that copy if you want the packaged skill there.`
|
||||
@@ -1135,6 +1133,13 @@ export function registerSessionRoutes(
|
||||
// materializing in ~/.claude/settings.local.json was never asked for.
|
||||
if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') {
|
||||
await applyWorkspaceHooks(workingDir, await ctx.getWorkspaceHooksEnabled());
|
||||
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
|
||||
// .claude rationale as the statusLine above: a create must never remove the
|
||||
// skill from under other live sessions in the repo. Marker-guarded, so a
|
||||
// user's own skills/codeman is never touched.
|
||||
if (await ctx.getAgentSkillEnabled()) {
|
||||
await injectAgentSkill(workingDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Refuse up front if the requested CLI cannot start, rather than spawning a pane that
|
||||
@@ -1158,18 +1163,6 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
|
||||
// workspace rationale as the statusLine above: a create must never remove the
|
||||
// skill from under other live sessions in the repo. Marker-guarded, so a user's
|
||||
// own copy is never touched. Every CLI that takes the skill gets it in the directory
|
||||
// that CLI scans (`capabilities.agentSkillDir`); the same remote and named-workingDir
|
||||
// conditions as the hooks above, and only once the launch pre-flight passed, so a
|
||||
// CLI that cannot start leaves nothing in the workspace.
|
||||
const createSkillDir = agentSkillDir(getCli(body.mode ?? 'claude')?.capabilities);
|
||||
if (!remote && body.workingDir && createSkillDir && (await ctx.getAgentSkillEnabled())) {
|
||||
await injectAgentSkill(workingDir, createSkillDir);
|
||||
}
|
||||
|
||||
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
||||
// in Claude's projects directory. If not, skip resume to avoid confusing
|
||||
// "No conversation found" errors from Claude CLI.
|
||||
@@ -1281,9 +1274,8 @@ export function registerSessionRoutes(
|
||||
ctx.persistSessionState(session);
|
||||
await ctx.setupSessionListeners(session);
|
||||
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
|
||||
// loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the
|
||||
// skill; best-effort.
|
||||
if (agentSkillDir(getCli(mode)?.capabilities) && !remote && (await ctx.getAgentSkillEnabled())) {
|
||||
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
|
||||
if (getCli(mode)?.capabilities.agentSkillInjection && !remote && (await ctx.getAgentSkillEnabled())) {
|
||||
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
|
||||
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
|
||||
);
|
||||
@@ -3878,14 +3870,12 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
// Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
|
||||
// marker-guarded (a user's own copy is never touched). Each CLI that takes the
|
||||
// skill gets it in the directory it scans (`capabilities.agentSkillDir`:
|
||||
// `.claude/skills` for claude, `.agents/skills` for most others); skipped for
|
||||
// remote cases, whose casePath lives on another host. Docker cases qualify:
|
||||
// hostWorkspacePath is a real host dir and the skill crosses the bind mount.
|
||||
const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities);
|
||||
if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) {
|
||||
await injectAgentSkill(resolvedCasePath, quickStartSkillDir);
|
||||
// marker-guarded (a user's own skills/codeman is never touched). Claude mode only
|
||||
// (`.claude/skills/` is a Claude Code surface); skipped for remote cases, whose
|
||||
// casePath lives on another host. Docker cases qualify: hostWorkspacePath is a
|
||||
// real host dir and the skill crosses the bind mount like the rest of `.claude/`.
|
||||
if (!remote && getCli(mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) {
|
||||
await injectAgentSkill(resolvedCasePath);
|
||||
}
|
||||
|
||||
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
||||
@@ -4198,9 +4188,8 @@ export function registerSessionRoutes(
|
||||
ctx.persistSessionState(session);
|
||||
await ctx.setupSessionListeners(session);
|
||||
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
|
||||
// loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the
|
||||
// skill; best-effort.
|
||||
if (agentSkillDir(getCli(mode)?.capabilities) && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
|
||||
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
|
||||
if (getCli(mode)?.capabilities.agentSkillInjection && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
|
||||
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
|
||||
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } f
|
||||
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js';
|
||||
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
|
||||
import { getAuthUser, isAdmin, requireAdmin, canAccessOwned } from '../route-helpers.js';
|
||||
import {
|
||||
ConfigUpdateSchema,
|
||||
SettingsUpdateSchema,
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
import { subagentWatcher } from '../../subagent-watcher.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { workflowRunWatcher } from '../../workflow-run-watcher.js';
|
||||
import { keepAwake } from '../../keep-awake-manager.js';
|
||||
import { resolveKeepAwakeConfig } from '../../keep-awake.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import {
|
||||
buildAwayDigest,
|
||||
@@ -1004,6 +1006,13 @@ export function registerSystemRoutes(
|
||||
// acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip
|
||||
// it before persisting so settings.json stays clean.
|
||||
const { acknowledgeUnauthTunnel, ...settingsToStore } = settings;
|
||||
// Keep-awake is machine state (an OS sleep lock), so in multi-user mode only an
|
||||
// admin changes it. A non-admin's save carries whatever value its page loaded, so
|
||||
// the keys are dropped rather than refused: refusing would fail every settings save.
|
||||
if (!isAdmin(req)) {
|
||||
delete settingsToStore.keepAwakeEnabled;
|
||||
delete settingsToStore.keepAwakeAcOnly;
|
||||
}
|
||||
const merged = { ...existing, ...settingsToStore };
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
|
||||
@@ -1032,6 +1041,10 @@ export function registerSystemRoutes(
|
||||
'Workflow run watcher'
|
||||
);
|
||||
|
||||
// Keep-awake reconciles from `merged` like the watchers above: a partial PUT
|
||||
// must not read as "turn it off".
|
||||
void keepAwake.apply(resolveKeepAwakeConfig(merged));
|
||||
|
||||
// Handle image watcher toggle dynamically
|
||||
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
|
||||
// Re-watch all active sessions that have image watcher enabled
|
||||
@@ -1082,6 +1095,14 @@ export function registerSystemRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Keep awake ==========
|
||||
|
||||
// Status of the sleep lock behind `keepAwakeEnabled` (src/keep-awake-manager.ts). A
|
||||
// plain in-memory read: the settings toggle lives in PUT /api/settings.
|
||||
app.get('/api/system/keep-awake', async () => {
|
||||
return { success: true, data: keepAwake.getStatus() };
|
||||
});
|
||||
|
||||
// ========== Model Configuration ==========
|
||||
|
||||
app.get('/api/execution/model-config', async () => {
|
||||
|
||||
@@ -1378,6 +1378,14 @@ export const SettingsUpdateSchema = z
|
||||
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
|
||||
*/
|
||||
mcpSyncEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Keep this computer awake while Codeman runs (src/keep-awake.ts). SYNCED, default OFF:
|
||||
* it is machine state (an OS sleep lock), so in multi-user mode only an admin can change
|
||||
* it; a non-admin's value is dropped by PUT /api/settings. `keepAwakeAcOnly` (default ON)
|
||||
* releases the lock on battery.
|
||||
*/
|
||||
keepAwakeEnabled: z.boolean().optional(),
|
||||
keepAwakeAcOnly: z.boolean().optional(),
|
||||
/**
|
||||
* Read My Mind predictor model override. Empty/absent = the AI-checker
|
||||
* default (opus: prediction quality is the product and it runs only on an
|
||||
|
||||
@@ -111,6 +111,8 @@ import { intentStore } from '../intent-store.js';
|
||||
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
|
||||
import { approvalInbox } from './approval-inbox.js';
|
||||
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
|
||||
import { keepAwake } from '../keep-awake-manager.js';
|
||||
import { resolveKeepAwakeConfig } from '../keep-awake.js';
|
||||
import {
|
||||
wireRespawnListeners,
|
||||
setupTimedRespawn,
|
||||
@@ -3142,6 +3144,10 @@ export class WebServer extends EventEmitter {
|
||||
console.log('Image watcher disabled by user settings');
|
||||
}
|
||||
|
||||
// Keep-awake: holds an OS sleep lock while this server runs (opt-in, default OFF).
|
||||
// A fresh read, like the gesture flag: it decides whether a lock is taken at all.
|
||||
void keepAwake.apply(resolveKeepAwakeConfig(await this.readSettings(true)));
|
||||
|
||||
// Tunnel only starts when user clicks the toggle in the UI — never on boot.
|
||||
// Reset persisted tunnelEnabled so the UI toggle reflects actual state.
|
||||
if (await this.isTunnelEnabled()) {
|
||||
@@ -3976,6 +3982,11 @@ export class WebServer extends EventEmitter {
|
||||
// got wrong once.
|
||||
void stopDeepSeekWeb();
|
||||
|
||||
// Release the sleep lock with the server, not after it. The lock would also drop on
|
||||
// its own once this process exits (stdin pipe / caffeinate -w), but a graceful stop
|
||||
// should not leave the macOS lid request file to go stale on its own.
|
||||
await keepAwake.stop();
|
||||
|
||||
// Same teardown rule: the per-endpoint llama-swap log tails are otherwise closed
|
||||
// only by the periodic idle sweep, whose interval is disposed just below.
|
||||
closeAllLlamaSwapLogTails();
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Agent skill per CLI: which workspace directory each CLI's sessions get
|
||||
* the one packaged skill in (`capabilities.agentSkillDir`), and the
|
||||
* `codeman skill install|uninstall` resolution that follows the registry.
|
||||
*
|
||||
* The skill is read from the REAL packaged source (`skills/codeman/`), so this also
|
||||
* guards that it meets the Agent Skills format every CLI (and the `npx skills`
|
||||
* installer) parses: a `name` equal to its directory, lowercase with hyphens, and a
|
||||
* description of at most 1024 characters.
|
||||
*
|
||||
* Pure filesystem and registry tests in a per-test temp dir. Port: N/A.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, readFile, readdir } from 'node:fs/promises';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { agentSkillDir, agentSkillDirFor, applyAgentSkill, CLAUDE_AGENT_SKILL_DIR } from '../src/hooks-config.js';
|
||||
import { getCli, listClis } from '../src/config/cli-registry/registry.js';
|
||||
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
|
||||
import { registryAgentSkillDirs, resolveSkillInstallDir } from '../src/cli.js';
|
||||
|
||||
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
|
||||
const AGENTS_DIR = '.agents/skills' as const;
|
||||
|
||||
/** The frontmatter fields the Agent Skills format defines, with a `>-` folded description joined. */
|
||||
function frontmatter(skillMd: string): { name?: string; description?: string } {
|
||||
const match = /^---\n([\s\S]*?)\n---\n/.exec(skillMd);
|
||||
if (!match) return {};
|
||||
const lines = match[1].split('\n');
|
||||
const out: { name?: string; description?: string } = {};
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const name = /^name:\s*(.+)$/.exec(lines[i]);
|
||||
if (name) out.name = name[1].trim();
|
||||
const desc = /^description:\s*(.*)$/.exec(lines[i]);
|
||||
if (desc) {
|
||||
if (desc[1].trim() === '>-' || desc[1].trim() === '>') {
|
||||
const folded: string[] = [];
|
||||
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) folded.push(lines[++i].trim());
|
||||
out.description = folded.join(' ');
|
||||
} else {
|
||||
out.description = desc[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let casePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
casePath = await mkdtemp(join(tmpdir(), 'codeman-agent-skill-dirs-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(casePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('the packaged skill meets the Agent Skills format', () => {
|
||||
it('skills/codeman/SKILL.md: name equals the directory, description within limits', () => {
|
||||
const fm = frontmatter(readFileSync(join(process.cwd(), 'skills', 'codeman', 'SKILL.md'), 'utf-8'));
|
||||
expect(fm.name).toBe('codeman');
|
||||
expect(fm.name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
||||
expect(fm.description, 'description').toBeTruthy();
|
||||
expect(fm.description!.length).toBeLessThanOrEqual(1024);
|
||||
// It refuses outside Codeman, and says so where every CLI reads first.
|
||||
expect(fm.description).toContain('CODEMAN_MUX=1');
|
||||
});
|
||||
|
||||
it('is the only packaged skill (one skill for every CLI)', async () => {
|
||||
expect(await readdir(join(process.cwd(), 'skills'))).toEqual(['codeman']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyAgentSkill with a directory', () => {
|
||||
it('installs into <case>/.agents/skills/codeman, marker appended, and nothing under .claude', async () => {
|
||||
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('installed');
|
||||
const skillDir = join(casePath, '.agents', 'skills', 'codeman');
|
||||
const skillMd = await readFile(join(skillDir, 'SKILL.md'), 'utf-8');
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
|
||||
expect(existsSync(join(casePath, '.claude'))).toBe(false);
|
||||
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('removes it again and prunes the emptied skills dir, never the CLI dir above it', async () => {
|
||||
await applyAgentSkill(casePath, true, AGENTS_DIR);
|
||||
expect(await applyAgentSkill(casePath, false, AGENTS_DIR)).toBe('removed');
|
||||
expect(existsSync(join(casePath, '.agents', 'skills'))).toBe(false);
|
||||
expect(existsSync(join(casePath, '.agents'))).toBe(true);
|
||||
});
|
||||
|
||||
it('two CLIs sharing one workspace each get an identical copy where they look', async () => {
|
||||
await applyAgentSkill(casePath, true, CLAUDE_AGENT_SKILL_DIR);
|
||||
await applyAgentSkill(casePath, true, AGENTS_DIR);
|
||||
const claudeCopy = await readFile(join(casePath, '.claude', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
|
||||
const agentsCopy = await readFile(join(casePath, '.agents', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
|
||||
expect(agentsCopy).toBe(claudeCopy);
|
||||
});
|
||||
|
||||
it("the default directory is still Claude Code's", async () => {
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('installed');
|
||||
expect(existsSync(agentSkillDirFor(casePath, CLAUDE_AGENT_SKILL_DIR))).toBe(true);
|
||||
});
|
||||
|
||||
it('writes nothing outside the target directory', async () => {
|
||||
await mkdir(join(casePath, 'keep'), { recursive: true });
|
||||
await applyAgentSkill(casePath, true, AGENTS_DIR);
|
||||
expect((await readdir(casePath)).sort()).toEqual(['.agents', 'keep']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentSkillDir', () => {
|
||||
it('is null when the CLI takes no skill', () => {
|
||||
expect(agentSkillDir({ agentSkillInjection: false, agentSkillDir: AGENTS_DIR })).toBeNull();
|
||||
expect(agentSkillDir(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to Claude Code's directory when only the old boolean is set (pre-field clis.json)", () => {
|
||||
expect(agentSkillDir({ agentSkillInjection: true })).toBe(CLAUDE_AGENT_SKILL_DIR);
|
||||
});
|
||||
|
||||
it('uses the declared directory otherwise', () => {
|
||||
expect(agentSkillDir({ agentSkillInjection: true, agentSkillDir: AGENTS_DIR })).toBe(AGENTS_DIR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the stock registry', () => {
|
||||
it('pins the per-CLI table (measured 2026-10-11, see AGENTS_DIR_SKILL in stock.ts)', () => {
|
||||
const table = Object.fromEntries(
|
||||
['claude', 'codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'shell'].map((id) => [
|
||||
id,
|
||||
agentSkillDir(getCli(id)?.capabilities),
|
||||
])
|
||||
);
|
||||
expect(table).toEqual({
|
||||
claude: '.claude/skills',
|
||||
codex: AGENTS_DIR,
|
||||
gemini: AGENTS_DIR,
|
||||
opencode: AGENTS_DIR,
|
||||
antigravity: AGENTS_DIR,
|
||||
// A project-level skill makes pi ask "Trust project folder?" at startup.
|
||||
pi: null,
|
||||
grok: AGENTS_DIR,
|
||||
deepseek: AGENTS_DIR,
|
||||
omp: AGENTS_DIR,
|
||||
shell: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilities.agentSkillDir validation (clis.json is user-editable)', () => {
|
||||
const withDir = (dir: unknown) => {
|
||||
const claude = getCli('claude')!;
|
||||
return CliEntrySchema.safeParse({ ...claude, capabilities: { ...claude.capabilities, agentSkillDir: dir } });
|
||||
};
|
||||
|
||||
it('accepts a listed directory', () => {
|
||||
expect(withDir('.agents/skills').success).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a free path, traversal included', () => {
|
||||
expect(withDir('../../.ssh').success).toBe(false);
|
||||
expect(withDir('/etc').success).toBe(false);
|
||||
expect(withDir('.pi/skills').success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codeman skill install|uninstall follow the registry', () => {
|
||||
it('--mode claude (the default) installs into .claude/skills, user-level or per case', () => {
|
||||
expect(resolveSkillInstallDir({})).toEqual({ skillDir: CLAUDE_AGENT_SKILL_DIR });
|
||||
expect(resolveSkillInstallDir({ mode: 'claude', case: 'x' })).toEqual({ skillDir: CLAUDE_AGENT_SKILL_DIR });
|
||||
});
|
||||
|
||||
it('--mode codex installs per case into .agents/skills, and points a user-level install at npx skills', () => {
|
||||
expect(resolveSkillInstallDir({ mode: 'codex', case: 'x' })).toEqual({ skillDir: AGENTS_DIR });
|
||||
expect(resolveSkillInstallDir({ mode: 'codex' }).error).toContain('npx skills add Ark0N/Codeman --skill codeman -g');
|
||||
});
|
||||
|
||||
it('refuses a mode without a skill and an unknown mode', () => {
|
||||
expect(resolveSkillInstallDir({ mode: 'shell', case: 'x' }).error).toContain('takes no Codeman agent skill');
|
||||
expect(resolveSkillInstallDir({ mode: 'nope', case: 'x' }).error).toContain('Unknown mode');
|
||||
});
|
||||
|
||||
it('uninstall --case covers every distinct directory the registry can write, once each', () => {
|
||||
const dirs = registryAgentSkillDirs();
|
||||
expect(new Set(dirs).size).toBe(dirs.length);
|
||||
expect(dirs).toContain(CLAUDE_AGENT_SKILL_DIR);
|
||||
for (const cli of listClis()) {
|
||||
const dir = agentSkillDir(cli.capabilities);
|
||||
if (dir) expect(dirs, cli.id).toContain(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -132,22 +132,20 @@ describe('linked-cases.json tolerance', () => {
|
||||
});
|
||||
|
||||
describe('skill command wiring', () => {
|
||||
it('registers install and uninstall, both accepting --case and --global, install also --mode', () => {
|
||||
it('registers install and uninstall, both accepting --case and --global', () => {
|
||||
const skill = program.commands.find((cmd) => cmd.name() === 'skill');
|
||||
expect(skill).toBeDefined();
|
||||
|
||||
const subcommands = skill!.commands.map((cmd) => cmd.name());
|
||||
expect(subcommands).toEqual(expect.arrayContaining(['install', 'uninstall']));
|
||||
|
||||
const flagsOf = (name: string) =>
|
||||
skill!.commands
|
||||
for (const name of ['install', 'uninstall']) {
|
||||
const flags = skill!.commands
|
||||
.find((cmd) => cmd.name() === name)!
|
||||
.options.map((opt) => opt.long)
|
||||
.sort();
|
||||
// --mode picks the skill and directory that run mode reads (capabilities.agentSkill);
|
||||
// uninstall needs none, since --case removes every Codeman-managed copy.
|
||||
expect(flagsOf('install')).toEqual(['--case', '--global', '--mode']);
|
||||
expect(flagsOf('uninstall')).toEqual(['--case', '--global']);
|
||||
expect(flags).toEqual(['--case', '--global']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/** @fileoverview Guards cron docs against drift from routes, schema, statuses, and UI entry points. */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CronJobSchema } from '../src/web/schemas.js';
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
const reference = read('docs/api-reference.md');
|
||||
const wiki = read('docs/wiki/HTTP-API.md');
|
||||
const guide = read('docs/wiki/Cron-Jobs.md');
|
||||
|
||||
const cronSection = (text: string) => text.split('## Cron jobs\n')[1]?.split('\n## ')[0] ?? '';
|
||||
|
||||
describe('cron documentation', () => {
|
||||
it('documents every cron route in both API references', () => {
|
||||
const routes = [...read('src/web/routes/cron-routes.ts').matchAll(/app\.(get|post|put|delete)\('([^']+)'/g)];
|
||||
expect(routes).toHaveLength(9);
|
||||
for (const [, method, path] of routes) {
|
||||
const row = `| ${method.toUpperCase()} | \`${path.replace('/api/', '/api/v1/')}\` |`;
|
||||
expect(cronSection(reference)).toContain(row);
|
||||
expect(cronSection(wiki)).toContain(row);
|
||||
}
|
||||
});
|
||||
|
||||
it('documents every accepted job field and validates the guide example', () => {
|
||||
for (const field of Object.keys(CronJobSchema.shape)) {
|
||||
expect(cronSection(reference)).toContain(`| \`${field}\` |`);
|
||||
}
|
||||
const example = guide.match(/-d '(\{[\s\S]*?\})'/)?.[1];
|
||||
expect(example).toBeDefined();
|
||||
expect(CronJobSchema.safeParse(JSON.parse(example!)).success).toBe(true);
|
||||
});
|
||||
|
||||
it('documents all run statuses without treating prompt delivery as task success', () => {
|
||||
const statusType = read('src/types/cron.ts').match(/export type CronJobRunStatus = ([^;]+);/)?.[1];
|
||||
expect(statusType).toBeDefined();
|
||||
for (const [, status] of statusType!.matchAll(/'([^']+)'/g)) {
|
||||
expect(guide).toContain(`| \`${status}\``);
|
||||
expect(cronSection(reference)).toContain(`\`${status}\``);
|
||||
}
|
||||
expect(guide.replace(/\s+/g, ' ')).toContain("not whether the agent's task succeeded");
|
||||
expect(guide).toContain('bottom toolbar');
|
||||
expect(guide).toContain('App Settings → Header & Panels → Scheduling');
|
||||
});
|
||||
|
||||
it('keeps linked pages consistent and explains non-terminal launch history', () => {
|
||||
expect(read('docs/wiki/The-Dashboard.md')).toMatch(/\| Cron\s*\| Bottom toolbar/);
|
||||
expect(read('docs/cron-guide.md')).not.toMatch(/Cron\*\* (?:button )?in the header/);
|
||||
for (const text of [reference, guide]) {
|
||||
const normalized = text.replace(/\s+/g, ' ');
|
||||
expect(normalized).toContain('session is closed during the readiness wait');
|
||||
expect(normalized).toContain('server restarts before delivery');
|
||||
expect(normalized).toContain('`session_started` indefinitely with `finishedAt: null`');
|
||||
}
|
||||
expect(reference).toContain("also closes the previous run's session before launching");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @fileoverview install.sh's keep-awake follow-up, driven in a real bash where it can be
|
||||
* (laptop detection over a fake sysfs, the settings.json write, the --yes default) and
|
||||
* pinned statically where it cannot (the macOS root helper needs sudo and launchd).
|
||||
*
|
||||
* The rules: never turned on by --yes or a headless run; the setting is written before
|
||||
* the service starts; the root helper runs from a ROOT-OWNED copy, never from the
|
||||
* user-writable install dir; uninstall undoes only what the helper set.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const INSTALL_SH = fileURLToPath(new URL('../install.sh', import.meta.url));
|
||||
const SOURCE = readFileSync(INSTALL_SH, 'utf-8');
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'install-keep-awake-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Source install.sh (library mode) in a real bash with HOME in the temp dir, then run `body`. */
|
||||
function drive(body: string, env: Record<string, string> = {}) {
|
||||
const script = `
|
||||
set -euo pipefail
|
||||
export CODEMAN_INSTALL_SH_LIB=1
|
||||
. "$1"
|
||||
${body}
|
||||
`;
|
||||
const result = spawnSync('bash', ['-c', script, 'bash', INSTALL_SH], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
input: '',
|
||||
env: { ...process.env, HOME: join(dir, 'home'), ...env },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function fakeSupply(name: string, files: Record<string, string>) {
|
||||
const d = join(dir, 'ps', name);
|
||||
mkdirSync(d, { recursive: true });
|
||||
for (const [k, v] of Object.entries(files)) writeFileSync(join(d, k), `${v}\n`);
|
||||
}
|
||||
|
||||
const laptopCheck = `
|
||||
POWER_SUPPLY_ROOT="${'$'}FAKE_PS"; LID_BUTTON_ROOT="${'$'}FAKE_LID"
|
||||
if is_laptop linux; then echo laptop; else echo desktop; fi
|
||||
`;
|
||||
|
||||
describe('is_laptop (Linux)', () => {
|
||||
const run = () => drive(laptopCheck, { FAKE_PS: join(dir, 'ps'), FAKE_LID: join(dir, 'lid') }).stdout.trim();
|
||||
|
||||
it('a machine with no battery and no lid is not a laptop', () => {
|
||||
mkdirSync(join(dir, 'ps'), { recursive: true });
|
||||
fakeSupply('AC', { type: 'Mains', online: '1' });
|
||||
expect(run()).toBe('desktop');
|
||||
});
|
||||
|
||||
it("a wireless mouse's battery does not make a desktop a laptop", () => {
|
||||
fakeSupply('hidpp_battery_0', { type: 'Battery', scope: 'Device', status: 'Discharging' });
|
||||
expect(run()).toBe('desktop');
|
||||
});
|
||||
|
||||
it('a system battery or a lid does', () => {
|
||||
fakeSupply('BAT0', { type: 'Battery', status: 'Charging' });
|
||||
expect(run()).toBe('laptop');
|
||||
rmSync(join(dir, 'ps'), { recursive: true });
|
||||
mkdirSync(join(dir, 'lid', 'LID0'), { recursive: true });
|
||||
expect(run()).toBe('laptop');
|
||||
});
|
||||
|
||||
it('copes with a missing power_supply tree', () => {
|
||||
expect(run()).toBe('desktop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('write_keep_awake_setting', () => {
|
||||
const settings = () => join(dir, 'home', '.codeman', 'settings.json');
|
||||
|
||||
it('creates settings.json with the setting on and AC-only on', () => {
|
||||
const r = drive('write_keep_awake_setting');
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(JSON.parse(readFileSync(settings(), 'utf-8'))).toEqual({ keepAwakeEnabled: true, keepAwakeAcOnly: true });
|
||||
});
|
||||
|
||||
it('keeps every other key, and an explicit AC-only choice', () => {
|
||||
mkdirSync(join(dir, 'home', '.codeman'), { recursive: true });
|
||||
writeFileSync(settings(), JSON.stringify({ theme: 'dark', keepAwakeAcOnly: false }, null, 2));
|
||||
expect(drive('write_keep_awake_setting').status).toBe(0);
|
||||
expect(JSON.parse(readFileSync(settings(), 'utf-8'))).toEqual({
|
||||
theme: 'dark',
|
||||
keepAwakeAcOnly: false,
|
||||
keepAwakeEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a settings file that does not parse untouched', () => {
|
||||
mkdirSync(join(dir, 'home', '.codeman'), { recursive: true });
|
||||
writeFileSync(settings(), '{ not json');
|
||||
const r = drive('write_keep_awake_setting || echo refused');
|
||||
expect(r.stdout).toContain('refused');
|
||||
expect(readFileSync(settings(), 'utf-8')).toBe('{ not json');
|
||||
});
|
||||
|
||||
it('is what keep_awake_enabled_now reads back', () => {
|
||||
const r = drive(
|
||||
'keep_awake_enabled_now && echo before; write_keep_awake_setting >/dev/null 2>&1; keep_awake_enabled_now && echo after'
|
||||
);
|
||||
expect(r.stdout.trim()).toBe('after');
|
||||
});
|
||||
});
|
||||
|
||||
describe('choose_keep_awake', () => {
|
||||
it('--yes on a laptop never turns it on', () => {
|
||||
fakeSupply('BAT0', { type: 'Battery', status: 'Charging' });
|
||||
const r = drive(
|
||||
`POWER_SUPPLY_ROOT="$FAKE_PS"; ASSUME_YES=1
|
||||
choose_keep_awake linux
|
||||
echo "keep=[$KEEP_AWAKE] helper=[$KEEP_AWAKE_LID_HELPER]"`,
|
||||
{ FAKE_PS: join(dir, 'ps') }
|
||||
);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('keep=[] helper=[]');
|
||||
});
|
||||
|
||||
it('asks nothing on a desktop', () => {
|
||||
const r = drive(
|
||||
`POWER_SUPPLY_ROOT="$FAKE_PS"; LID_BUTTON_ROOT="$FAKE_PS"
|
||||
choose_keep_awake linux 2>&1
|
||||
echo "keep=[$KEEP_AWAKE]"`,
|
||||
{ FAKE_PS: join(dir, 'nothing') }
|
||||
);
|
||||
expect(r.stdout.trim()).toBe('keep=[]');
|
||||
});
|
||||
|
||||
it('only notes an existing setting, without re-asking', () => {
|
||||
mkdirSync(join(dir, 'home', '.codeman'), { recursive: true });
|
||||
writeFileSync(join(dir, 'home', '.codeman', 'settings.json'), JSON.stringify({ keepAwakeEnabled: true }, null, 2));
|
||||
const r = drive(`choose_keep_awake linux force 2>&1; echo "keep=[$KEEP_AWAKE]"`);
|
||||
expect(r.stdout).toContain('already on');
|
||||
expect(r.stdout).toContain('keep=[]');
|
||||
});
|
||||
|
||||
it('defaults to no in the prompt itself', () => {
|
||||
const fn = SOURCE.slice(SOURCE.indexOf('choose_keep_awake() {'), SOURCE.indexOf('write_keep_awake_setting() {'));
|
||||
expect(fn).toMatch(/prompt_yes_no "Keep this machine awake[^"]*" "n"/);
|
||||
// The sudo-needing helper is offered only to a person at a terminal.
|
||||
expect(fn).toMatch(/"\$NONINTERACTIVE" != "1" && "\$ASSUME_YES" != "1" \]\] && has_tty/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh keep-awake wiring', () => {
|
||||
const fn = (name: string, next: string) => SOURCE.slice(SOURCE.indexOf(`${name}() {`), SOURCE.indexOf(next));
|
||||
|
||||
it('asks after question 3 and applies before the service starts', () => {
|
||||
const main = fn('main', '\npreflight_detect() {');
|
||||
const ask = main.indexOf('choose_keep_awake "$os"');
|
||||
const apply = main.indexOf('apply_keep_awake');
|
||||
expect(ask).toBeGreaterThan(main.indexOf('choose_launch_mode "$os"'));
|
||||
expect(ask).toBeLessThan(main.indexOf('install_or_update_repo'));
|
||||
expect(apply).toBeGreaterThan(main.indexOf('run_step "Building Codeman"'));
|
||||
expect(apply).toBeLessThan(main.indexOf('setup_launchd_service'));
|
||||
expect(apply).toBeLessThan(main.indexOf('setup_systemd_service'));
|
||||
});
|
||||
|
||||
it('runs the root helper from a root-owned copy, never from the install dir', () => {
|
||||
const body = fn('install_keep_awake_lid_helper', '\nremove_keep_awake_lid_helper() {');
|
||||
expect(body).toContain('install -m 755 -o root -g wheel "$src" "$script"');
|
||||
expect(body).toContain('local script="$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh"');
|
||||
const programArgs = body.slice(body.indexOf('<key>ProgramArguments</key>'), body.indexOf('</array>'));
|
||||
expect(programArgs).toContain('$(xml_escape "$script")');
|
||||
expect(programArgs).not.toContain('INSTALL_DIR');
|
||||
// The request path must match what the server writes (dataPath('keep-awake-lid.pid')).
|
||||
expect(body).toContain('local request="$HOME/.codeman/keep-awake-lid.pid"');
|
||||
});
|
||||
|
||||
it('uninstall removes the helper and undoes only a disablesleep it set', () => {
|
||||
expect(fn('uninstall', '\nusage() {')).toContain('remove_keep_awake_lid_helper');
|
||||
const remove = fn('remove_keep_awake_lid_helper', '\napply_keep_awake() {');
|
||||
const owned = remove.indexOf('keep-awake.owned');
|
||||
expect(owned).toBeGreaterThan(-1);
|
||||
expect(remove.indexOf('pmset -a disablesleep 0')).toBeGreaterThan(owned);
|
||||
});
|
||||
|
||||
it('dispatches the keep-awake subcommand and documents it', () => {
|
||||
expect(SOURCE).toMatch(/update\|uninstall\|tailscale\|name\|status\|cloudflared\|keep-awake\)/);
|
||||
expect(SOURCE).toMatch(/\n {4}keep-awake\) {2}keep_awake_subcommand ;;/);
|
||||
const header = SOURCE.slice(0, SOURCE.indexOf('set -euo pipefail'));
|
||||
expect(header).toContain('install.sh keep-awake');
|
||||
expect(fn('usage', '\nparse_flags() {')).toContain('keep-awake');
|
||||
});
|
||||
|
||||
it('update and keep-awake share one restart path', () => {
|
||||
expect(fn('update', '\nuninstall() {')).toContain('restart_running_service');
|
||||
expect(fn('keep_awake_subcommand', '\nverify_systemd_active() {')).toContain('restart_running_service');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @fileoverview Runs the real macOS lid helper (scripts/keep-awake-macos.sh) in bash
|
||||
* against a stub `pmset` that records what it was asked to do. The helper runs as root
|
||||
* from a LaunchDaemon, so the cases that matter are the ones where it must NOT act: a
|
||||
* stale, dead, foreign or symlinked request, and an administrator's own
|
||||
* `disablesleep 1`, which it must never switch off.
|
||||
*
|
||||
* Only the BSD/GNU-portable tools the script uses (find -mmin, ps -o uid=, ls -ln) run
|
||||
* here, so this passes on Linux and macOS alike. Port: none.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const HELPER = fileURLToPath(new URL('../scripts/keep-awake-macos.sh', import.meta.url));
|
||||
|
||||
let dir: string;
|
||||
let stateDir: string;
|
||||
let request: string;
|
||||
|
||||
/** Stub pmset: `-g` prints the stored SleepDisabled, `-a disablesleep N` stores N and logs it. */
|
||||
function writeStubPmset(initial: '0' | '1' | null) {
|
||||
const value = join(dir, 'sleepdisabled');
|
||||
if (initial !== null) writeFileSync(value, initial);
|
||||
const stub = join(dir, 'pmset');
|
||||
writeFileSync(
|
||||
stub,
|
||||
`#!/bin/bash
|
||||
if [ "$1" = "-g" ]; then
|
||||
echo "System-wide power settings:"
|
||||
[ -f "${value}" ] && printf ' SleepDisabled\\t\\t%s\\n' "$(cat "${value}")"
|
||||
echo "Currently in use:"
|
||||
echo " sleep 1"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "-a" ] && [ "$2" = "disablesleep" ]; then
|
||||
printf '%s' "$3" > "${value}"
|
||||
echo "disablesleep $3" >> "${join(dir, 'pmset.log')}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`
|
||||
);
|
||||
chmodSync(stub, 0o755);
|
||||
return stub;
|
||||
}
|
||||
|
||||
function run() {
|
||||
const result = spawnSync('bash', [HELPER, request], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
CODEMAN_KEEPAWAKE_PMSET: join(dir, 'pmset'),
|
||||
CODEMAN_KEEPAWAKE_STATE_DIR: stateDir,
|
||||
},
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
}
|
||||
|
||||
const calls = () => (existsSync(join(dir, 'pmset.log')) ? readFileSync(join(dir, 'pmset.log'), 'utf-8') : '');
|
||||
const sleepDisabled = () => readFileSync(join(dir, 'sleepdisabled'), 'utf-8');
|
||||
const owned = () => existsSync(join(stateDir, 'keep-awake.owned'));
|
||||
const requestFromLiveServer = () => writeFileSync(request, `${process.pid}\n`);
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'keep-awake-helper-'));
|
||||
stateDir = join(dir, 'state');
|
||||
request = join(dir, 'keep-awake-lid.pid');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('keep-awake-macos.sh', () => {
|
||||
it('does nothing without a request', () => {
|
||||
writeStubPmset('0');
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
expect(owned()).toBe(false);
|
||||
});
|
||||
|
||||
it('applies disablesleep for a fresh request from a live process, then undoes it when the request goes', () => {
|
||||
writeStubPmset('0');
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
expect(owned()).toBe(true);
|
||||
|
||||
run(); // steady state: no repeated pmset call
|
||||
expect(calls()).toBe('disablesleep 1\n');
|
||||
|
||||
rmSync(request);
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('0');
|
||||
expect(owned()).toBe(false);
|
||||
expect(calls()).toBe('disablesleep 1\ndisablesleep 0\n');
|
||||
});
|
||||
|
||||
it("never switches off an administrator's own disablesleep", () => {
|
||||
writeStubPmset('1');
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
expect(owned()).toBe(false);
|
||||
rmSync(request);
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
expect(calls()).toBe('');
|
||||
});
|
||||
|
||||
it('treats an absent SleepDisabled line as 0', () => {
|
||||
writeStubPmset(null);
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
expect(owned()).toBe(true);
|
||||
});
|
||||
|
||||
it('re-applies after a reboot or OS update reset it, while the request stands', () => {
|
||||
writeStubPmset('0');
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
writeFileSync(join(dir, 'sleepdisabled'), '0'); // the reset
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
});
|
||||
|
||||
it('ignores a stale request (a crashed or hung server) and releases', () => {
|
||||
writeStubPmset('0');
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
const threeMinutesAgo = (Date.now() - 3 * 60_000) / 1000;
|
||||
utimesSync(request, threeMinutesAgo, threeMinutesAgo);
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('0');
|
||||
expect(owned()).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a request whose pid is not running', () => {
|
||||
writeStubPmset('0');
|
||||
const gone = spawnSync('true').pid;
|
||||
writeFileSync(request, `${gone}\n`);
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
});
|
||||
|
||||
it('ignores garbage and refuses a symlinked request', () => {
|
||||
writeStubPmset('0');
|
||||
writeFileSync(request, 'not-a-pid\n');
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
|
||||
rmSync(request);
|
||||
const real = join(dir, 'elsewhere.pid');
|
||||
writeFileSync(real, `${process.pid}\n`);
|
||||
symlinkSync(real, request);
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* @fileoverview KeepAwakeManager (src/keep-awake-manager.ts) driven through fake deps: no
|
||||
* real lock is ever taken. Pins the lifecycle: a lock is confirmed before it reads as
|
||||
* active, a polkit refusal is a retried state, AC-only follows the power source, a
|
||||
* release closes the inhibitor's stdin (which is what drops the lock), and the macOS lid
|
||||
* request file follows the lock only when the root helper is installed.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { KeepAwakeManager, type KeepAwakeDeps } from '../src/keep-awake-manager.js';
|
||||
import {
|
||||
LINUX_HELD_MARKER,
|
||||
POWER_POLL_MS,
|
||||
RETRY_MS,
|
||||
linuxInhibitArgs,
|
||||
macCaffeinateArgs,
|
||||
type PowerSupplyInfo,
|
||||
} from '../src/keep-awake.js';
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
stdout = new EventEmitter();
|
||||
stderr = new EventEmitter();
|
||||
/** Like the real `cat`: closing stdin ends the process (asynchronously). */
|
||||
stdin = Object.assign(new EventEmitter(), {
|
||||
end: vi.fn(() => {
|
||||
queueMicrotask(() => {
|
||||
if (this.exitCode !== null || this.signalCode !== null) return;
|
||||
this.exitCode = 0;
|
||||
this.emit('exit', 0, null);
|
||||
});
|
||||
}),
|
||||
});
|
||||
exitCode: number | null = null;
|
||||
signalCode: string | null = null;
|
||||
kill = vi.fn((signal?: string) => {
|
||||
this.signalCode = signal ?? 'SIGTERM';
|
||||
this.emit('exit', null, this.signalCode);
|
||||
return true;
|
||||
});
|
||||
/** The process announces the lock (systemd-inhibit only execs its child once held). */
|
||||
hold() {
|
||||
this.stdout.emit('data', Buffer.from(`${LINUX_HELD_MARKER}\n`));
|
||||
}
|
||||
fail(stderr: string, code = 1) {
|
||||
this.stderr.emit('data', Buffer.from(stderr));
|
||||
this.exitCode = code;
|
||||
this.emit('exit', code, null);
|
||||
}
|
||||
}
|
||||
|
||||
const AC: PowerSupplyInfo[] = [
|
||||
{ type: 'Mains', online: true },
|
||||
{ type: 'Battery', status: 'Charging' },
|
||||
];
|
||||
const BATTERY: PowerSupplyInfo[] = [
|
||||
{ type: 'Mains', online: false },
|
||||
{ type: 'Battery', status: 'Discharging' },
|
||||
];
|
||||
|
||||
/** Let queued reconciles (a promise chain) run to completion. */
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r));
|
||||
};
|
||||
|
||||
function makeDeps(overrides: Partial<KeepAwakeDeps> = {}) {
|
||||
const children: FakeChild[] = [];
|
||||
let supplies = AC;
|
||||
let batt = "Now drawing from 'AC Power'";
|
||||
const deps: KeepAwakeDeps = {
|
||||
platform: 'linux',
|
||||
serverPid: 4242,
|
||||
spawn: vi.fn(() => {
|
||||
const c = new FakeChild();
|
||||
children.push(c);
|
||||
return c as unknown as ChildProcess;
|
||||
}),
|
||||
readPowerSupplies: vi.fn(async () => supplies),
|
||||
readMacBatt: vi.fn(async () => batt),
|
||||
lidHelperInstalled: vi.fn(async () => true),
|
||||
writeLidRequest: vi.fn(async () => {}),
|
||||
removeLidRequest: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
deps,
|
||||
children,
|
||||
setSupplies: (s: PowerSupplyInfo[]) => (supplies = s),
|
||||
setBatt: (s: string) => (batt = s),
|
||||
};
|
||||
}
|
||||
|
||||
describe('KeepAwakeManager on Linux', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('takes the logind lock and reads active only once the lock is confirmed', async () => {
|
||||
const { deps, children } = makeDeps();
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
expect(deps.spawn).toHaveBeenCalledWith('systemd-inhibit', linuxInhibitArgs());
|
||||
expect(m.getStatus().state).toBe('starting');
|
||||
children[0].hold();
|
||||
expect(m.getStatus()).toMatchObject({ state: 'active', onAc: true, platform: 'linux', lidHelper: null });
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it('reports a polkit refusal as denied and retries it', async () => {
|
||||
const { deps, children } = makeDeps();
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
children[0].fail('Failed to inhibit: Access denied\n');
|
||||
expect(m.getStatus().state).toBe('denied');
|
||||
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(RETRY_MS);
|
||||
await settle();
|
||||
expect(deps.spawn).toHaveBeenCalledTimes(2);
|
||||
children[1].hold();
|
||||
expect(m.getStatus().state).toBe('active');
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it('turning it off closes the inhibitor stdin, which drops the lock, and does not retry', async () => {
|
||||
const { deps, children } = makeDeps();
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: false });
|
||||
children[0].hold();
|
||||
await m.apply({ enabled: false, acOnly: false });
|
||||
expect(children[0].stdin.end).toHaveBeenCalled();
|
||||
expect(m.getStatus().state).toBe('off');
|
||||
// The released child exiting is expected, not a failure to retry.
|
||||
await settle();
|
||||
await vi.advanceTimersByTimeAsync(RETRY_MS * 2);
|
||||
await settle();
|
||||
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
||||
expect(m.getStatus().state).toBe('off');
|
||||
});
|
||||
|
||||
it('AC-only: no lock on battery, takes it when plugged in, releases on unplug', async () => {
|
||||
const { deps, children, setSupplies } = makeDeps();
|
||||
setSupplies(BATTERY);
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
expect(m.getStatus()).toMatchObject({ state: 'paused-battery', onAc: false });
|
||||
|
||||
setSupplies(AC);
|
||||
await vi.advanceTimersByTimeAsync(POWER_POLL_MS);
|
||||
await settle();
|
||||
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
||||
children[0].hold();
|
||||
expect(m.getStatus().state).toBe('active');
|
||||
|
||||
setSupplies(BATTERY);
|
||||
await vi.advanceTimersByTimeAsync(POWER_POLL_MS);
|
||||
await settle();
|
||||
expect(children[0].stdin.end).toHaveBeenCalled();
|
||||
expect(m.getStatus().state).toBe('paused-battery');
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it('without AC-only it never reads the power source', async () => {
|
||||
const { deps, setSupplies } = makeDeps();
|
||||
setSupplies(BATTERY);
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: false });
|
||||
expect(deps.readPowerSupplies).not.toHaveBeenCalled();
|
||||
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it('a machine without systemd-inhibit is unavailable, and is not retried', async () => {
|
||||
const { deps } = makeDeps({
|
||||
spawn: vi.fn(() => {
|
||||
throw Object.assign(new Error('spawn systemd-inhibit ENOENT'), { code: 'ENOENT' });
|
||||
}),
|
||||
});
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
expect(m.getStatus()).toMatchObject({ state: 'unavailable' });
|
||||
expect(m.getStatus().detail).toMatch(/systemd-inhibit is not installed/);
|
||||
await vi.advanceTimersByTimeAsync(RETRY_MS * 3);
|
||||
await settle();
|
||||
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it('stop() releases the lock and leaves nothing scheduled', async () => {
|
||||
const { deps, children } = makeDeps();
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
children[0].hold();
|
||||
await m.stop();
|
||||
await settle();
|
||||
expect(children[0].stdin.end).toHaveBeenCalled();
|
||||
expect(children[0].kill).not.toHaveBeenCalled();
|
||||
expect(m.getStatus().state).toBe('off');
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KeepAwakeManager on macOS', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('runs caffeinate tied to the server pid and keeps the lid request fresh while held', async () => {
|
||||
const { deps, children } = makeDeps({ platform: 'macos' });
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
expect(deps.spawn).toHaveBeenCalledWith('/usr/bin/caffeinate', macCaffeinateArgs(4242));
|
||||
children[0].emit('spawn');
|
||||
expect(m.getStatus()).toMatchObject({ state: 'active', lidHelper: 'installed', onAc: true });
|
||||
expect(deps.writeLidRequest).toHaveBeenCalledWith(4242);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect((deps.writeLidRequest as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await m.apply({ enabled: false, acOnly: true });
|
||||
expect(deps.removeLidRequest).toHaveBeenCalled();
|
||||
expect(children[0].kill).toHaveBeenCalled();
|
||||
const writes = (deps.writeLidRequest as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect((deps.writeLidRequest as ReturnType<typeof vi.fn>).mock.calls.length).toBe(writes);
|
||||
});
|
||||
|
||||
it('writes no lid request when the root helper is not installed', async () => {
|
||||
const { deps, children } = makeDeps({ platform: 'macos', lidHelperInstalled: vi.fn(async () => false) });
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
children[0].emit('spawn');
|
||||
expect(m.getStatus()).toMatchObject({ state: 'active', lidHelper: 'missing' });
|
||||
expect(deps.writeLidRequest).not.toHaveBeenCalled();
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it('on battery with AC-only, drops both caffeinate and the lid request', async () => {
|
||||
const { deps, children, setBatt } = makeDeps({ platform: 'macos' });
|
||||
const m = new KeepAwakeManager(deps);
|
||||
await m.apply({ enabled: true, acOnly: true });
|
||||
children[0].emit('spawn');
|
||||
setBatt("Now drawing from 'Battery Power'");
|
||||
await vi.advanceTimersByTimeAsync(POWER_POLL_MS);
|
||||
await settle();
|
||||
expect(m.getStatus().state).toBe('paused-battery');
|
||||
expect(children[0].kill).toHaveBeenCalled();
|
||||
expect(deps.removeLidRequest).toHaveBeenCalled();
|
||||
await m.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the process-wide manager under vitest', () => {
|
||||
it('is inert: enabling it never spawns a real lock', async () => {
|
||||
const m = new KeepAwakeManager();
|
||||
await m.apply({ enabled: true, acOnly: false });
|
||||
expect(m.getStatus()).toMatchObject({ state: 'unavailable', detail: 'Disabled under the test runner.' });
|
||||
await m.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @fileoverview Pure decisions behind "Keep this computer awake while Codeman runs"
|
||||
* (src/keep-awake.ts): settings defaults, platform choice, power-source parsing, the
|
||||
* hold/release rule, the lock argv and the failure classification.
|
||||
*
|
||||
* Port: none (pure).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
LINUX_HELD_MARKER,
|
||||
LINUX_INHIBIT_WHAT,
|
||||
classifyInhibitFailure,
|
||||
isOnAcPowerLinux,
|
||||
isOnAcPowerMac,
|
||||
keepAwakePlatform,
|
||||
linuxInhibitArgs,
|
||||
macCaffeinateArgs,
|
||||
resolveKeepAwakeConfig,
|
||||
shouldHoldLock,
|
||||
} from '../src/keep-awake.js';
|
||||
|
||||
describe('resolveKeepAwakeConfig', () => {
|
||||
it('is off unless explicitly enabled, and AC-only unless explicitly not', () => {
|
||||
expect(resolveKeepAwakeConfig({})).toEqual({ enabled: false, acOnly: true });
|
||||
expect(resolveKeepAwakeConfig({ keepAwakeEnabled: 'yes' })).toEqual({ enabled: false, acOnly: true });
|
||||
expect(resolveKeepAwakeConfig({ keepAwakeEnabled: true })).toEqual({ enabled: true, acOnly: true });
|
||||
expect(resolveKeepAwakeConfig({ keepAwakeEnabled: true, keepAwakeAcOnly: false })).toEqual({
|
||||
enabled: true,
|
||||
acOnly: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('keepAwakePlatform', () => {
|
||||
it('maps darwin and linux, and treats WSL as unsupported', () => {
|
||||
expect(keepAwakePlatform('darwin', '24.0.0')).toBe('macos');
|
||||
expect(keepAwakePlatform('linux', '6.8.0-124-generic')).toBe('linux');
|
||||
expect(keepAwakePlatform('linux', '5.15.153.1-microsoft-standard-WSL2')).toBe('unsupported');
|
||||
expect(keepAwakePlatform('win32', '10.0.22631')).toBe('unsupported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOnAcPowerLinux', () => {
|
||||
const battery = (status: string) => ({ type: 'Battery', status });
|
||||
it('a machine with no battery of its own is on external power', () => {
|
||||
expect(isOnAcPowerLinux([])).toBe(true);
|
||||
expect(isOnAcPowerLinux([{ type: 'Battery', status: 'Discharging', scope: 'Device' }])).toBe(true);
|
||||
});
|
||||
it('an online adapter means AC, all adapters offline means battery', () => {
|
||||
expect(isOnAcPowerLinux([{ type: 'Mains', online: true }, battery('Charging')])).toBe(true);
|
||||
expect(isOnAcPowerLinux([{ type: 'USB', online: true }, battery('Unknown')])).toBe(true);
|
||||
expect(isOnAcPowerLinux([{ type: 'Mains', online: false }, battery('Unknown')])).toBe(false);
|
||||
});
|
||||
it('falls back to the battery status when no adapter is listed', () => {
|
||||
expect(isOnAcPowerLinux([battery('Discharging')])).toBe(false);
|
||||
expect(isOnAcPowerLinux([battery('Full')])).toBe(true);
|
||||
expect(isOnAcPowerLinux([battery('Not charging')])).toBe(true);
|
||||
expect(isOnAcPowerLinux([battery('Unknown')])).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOnAcPowerMac', () => {
|
||||
it('reads the source from `pmset -g batt`', () => {
|
||||
expect(isOnAcPowerMac("Now drawing from 'AC Power'\n -InternalBattery-0 (id=1)\t100%; charged;")).toBe(true);
|
||||
expect(isOnAcPowerMac("Now drawing from 'Battery Power'\n -InternalBattery-0 (id=1)\t80%;")).toBe(false);
|
||||
expect(isOnAcPowerMac("Now drawing from 'UPS Power'")).toBe(false);
|
||||
expect(isOnAcPowerMac('')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldHoldLock', () => {
|
||||
it('holds only when enabled, and on battery only when not AC-only', () => {
|
||||
expect(shouldHoldLock({ enabled: false, acOnly: false }, true)).toBe(false);
|
||||
expect(shouldHoldLock({ enabled: true, acOnly: true }, true)).toBe(true);
|
||||
expect(shouldHoldLock({ enabled: true, acOnly: true }, false)).toBe(false);
|
||||
expect(shouldHoldLock({ enabled: true, acOnly: false }, false)).toBe(true);
|
||||
});
|
||||
it('treats an unknown power source as AC (a desktop without battery reporting)', () => {
|
||||
expect(shouldHoldLock({ enabled: true, acOnly: true }, null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lock argv', () => {
|
||||
it('asks logind for the lid lock, not just sleep (LidSwitchIgnoreInhibited=yes ignores sleep)', () => {
|
||||
const args = linuxInhibitArgs();
|
||||
expect(LINUX_INHIBIT_WHAT.split(':')).toContain('handle-lid-switch');
|
||||
expect(args).toContain(`--what=${LINUX_INHIBIT_WHAT}`);
|
||||
expect(args).toContain('--mode=block');
|
||||
});
|
||||
it('ends in a cat on stdin, so the lock dies with the server', () => {
|
||||
const args = linuxInhibitArgs();
|
||||
const script = args[args.length - 1];
|
||||
expect(args.slice(-3, -1)).toEqual(['/bin/sh', '-c']);
|
||||
expect(script).toBe(`echo ${LINUX_HELD_MARKER}; exec cat`);
|
||||
expect(script).not.toMatch(/sleep/);
|
||||
});
|
||||
it('ties caffeinate to the server pid', () => {
|
||||
expect(macCaffeinateArgs(4242)).toEqual(['-i', '-s', '-w', '4242']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyInhibitFailure', () => {
|
||||
it('reads a polkit refusal as denied', () => {
|
||||
expect(classifyInhibitFailure('Failed to inhibit: Access denied\n').state).toBe('denied');
|
||||
expect(classifyInhibitFailure('Interactive authentication required.').state).toBe('denied');
|
||||
});
|
||||
it('reads a missing logind as unavailable', () => {
|
||||
expect(classifyInhibitFailure('Failed to connect to bus: No such file or directory').state).toBe('unavailable');
|
||||
});
|
||||
it('keeps the first line of anything else', () => {
|
||||
const r = classifyInhibitFailure('something odd\nmore');
|
||||
expect(r).toEqual({ state: 'failed', detail: 'something odd' });
|
||||
expect(classifyInhibitFailure('').detail).toBe('the inhibitor exited');
|
||||
});
|
||||
});
|
||||
@@ -10,25 +10,16 @@
|
||||
* Uses app.inject(), so no real HTTP port is needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdtemp, rm, readFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { resolveCliLaunchError } from '../../src/utils/cli-launcher.js';
|
||||
|
||||
// External CLIs are pre-flighted (is the binary there?) before anything touches the
|
||||
// workspace; CI has none installed, so the pre-flight is stubbed to "can start" and one
|
||||
// test flips it to a refusal.
|
||||
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
|
||||
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
|
||||
|
||||
@@ -113,58 +104,6 @@ describe('POST /api/sessions agent-skill injection', () => {
|
||||
expect(existsSync(skillDir())).toBe(false);
|
||||
});
|
||||
|
||||
it('gives a codex session the skill in .agents/skills, where codex looks, and none in .claude', async () => {
|
||||
harness = await createHarness(true);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { name: 'skill-codex', mode: 'codex', workingDir },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const skillMd = await readFile(join(workingDir, '.agents', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
expect(existsSync(join(workingDir, '.agents', 'skills', 'codeman', 'reference', 'endpoints.md'))).toBe(true);
|
||||
expect(existsSync(skillDir())).toBe(false);
|
||||
|
||||
// The preamble cache is seeded for it too, so the skill's §0 is the two-line loader
|
||||
// in codex as in claude (nothing in the preamble is claude-specific).
|
||||
const sessionId = (JSON.parse(res.body) as { session: { id: string } }).session.id;
|
||||
const preamble = join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), `codeman-agent-${sessionId}.sh`);
|
||||
expect(existsSync(preamble)).toBe(true);
|
||||
});
|
||||
|
||||
it('gives a pi session nothing: a project skill would put up pi\'s trust prompt', async () => {
|
||||
harness = await createHarness(true);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { name: 'skill-pi', mode: 'pi', workingDir },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(existsSync(join(workingDir, '.agents'))).toBe(false);
|
||||
expect(existsSync(join(workingDir, '.pi'))).toBe(false);
|
||||
expect(harness.ctx.getAgentSkillEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes nothing when the CLI cannot start (pre-flight refusal)', async () => {
|
||||
vi.mocked(resolveCliLaunchError).mockResolvedValueOnce('codex not found');
|
||||
harness = await createHarness(true);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { name: 'skill-codex-missing', mode: 'codex', workingDir },
|
||||
});
|
||||
|
||||
expect(JSON.parse(res.body).success).toBe(false);
|
||||
expect(existsSync(join(workingDir, '.agents'))).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults an omitted mode to claude and injects', async () => {
|
||||
// The frontend omits `mode` for a plain claude create, so the default branch
|
||||
// is the common path, not an edge case.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @fileoverview Keep-awake wiring in system-routes: PUT /api/settings reconciles the sleep
|
||||
* lock from the MERGED settings (a partial body never reads as "turn it off"), a
|
||||
* non-admin's value is dropped in multi-user mode (it is machine state), and
|
||||
* GET /api/system/keep-awake returns the manager's status in the standard envelope.
|
||||
*
|
||||
* Uses app.inject(); the manager is mocked, so no real lock is taken. Port: N/A.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||
|
||||
const { store, keepAwake } = vi.hoisted(() => ({
|
||||
store: { settings: {} as Record<string, unknown>, written: [] as Record<string, unknown>[] },
|
||||
keepAwake: {
|
||||
apply: vi.fn(async () => {}),
|
||||
getStatus: vi.fn(() => ({
|
||||
enabled: true,
|
||||
acOnly: true,
|
||||
platform: 'linux',
|
||||
state: 'active',
|
||||
onAc: true,
|
||||
lidHelper: null,
|
||||
detail: null,
|
||||
})),
|
||||
stop: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readFile: vi.fn(async () => JSON.stringify(store.settings)),
|
||||
writeFile: vi.fn(async (_path: string, data: string) => {
|
||||
store.written.push(JSON.parse(data));
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
|
||||
});
|
||||
|
||||
vi.mock('../../src/keep-awake-manager.js', () => ({ keepAwake }));
|
||||
|
||||
describe('keep-awake in system routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
store.settings = {};
|
||||
store.written = [];
|
||||
keepAwake.apply.mockClear();
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
await harness?.app.close();
|
||||
});
|
||||
|
||||
it('a partial PUT keeps the persisted keep-awake on', async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
store.settings = { keepAwakeEnabled: true, keepAwakeAcOnly: false };
|
||||
const res = await harness.app.inject({ method: 'PUT', url: '/api/settings', payload: { showMonitor: true } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: false });
|
||||
});
|
||||
|
||||
it('an explicit PUT turns it on and persists it', async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { keepAwakeEnabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(store.written.at(-1)).toMatchObject({ keepAwakeEnabled: true });
|
||||
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: true });
|
||||
});
|
||||
|
||||
it('rejects a non-boolean value', async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { keepAwakeEnabled: 'yes' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(keepAwake.apply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a non-admin's value in multi-user mode, without failing the rest of the save", async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
harness = await createRouteTestHarness(registerSystemRoutes, {
|
||||
authUser: { username: 'bob', role: 'user' },
|
||||
});
|
||||
store.settings = { keepAwakeEnabled: false };
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { keepAwakeEnabled: true, keepAwakeAcOnly: false, showMonitor: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const written = store.written.at(-1)!;
|
||||
expect(written.keepAwakeEnabled).toBe(false);
|
||||
expect('keepAwakeAcOnly' in written).toBe(false);
|
||||
expect(written.showMonitor).toBe(true);
|
||||
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: false, acOnly: true });
|
||||
});
|
||||
|
||||
it('an admin can change it in multi-user mode', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
harness = await createRouteTestHarness(registerSystemRoutes, {
|
||||
authUser: { username: 'root', role: 'admin' },
|
||||
});
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { keepAwakeEnabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: true });
|
||||
});
|
||||
|
||||
it('GET /api/system/keep-awake returns the status envelope', async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/system/keep-awake' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ success: true, data: keepAwake.getStatus() });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user