@armstrongnate/april 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  She does all the work so you don't have to, and with about the same level of enthusiasm.
4
4
 
5
- april watches for GitHub issues assigned to you with a specific label, then spins up a Claude Code session in a tmux window to work the issue end-to-end — from reading the issue to opening a PR.
5
+ april watches for GitHub issues assigned to you with a specific label, then spins up a coding-agent session in a tmux window to work the issue end-to-end — from reading the issue to opening a PR. Supports [Claude Code](https://claude.ai/claude-code) and [OpenAI Codex CLI](https://developers.openai.com/codex/cli) — pick one per install via config.
6
6
 
7
7
  ## Prerequisites
8
8
 
@@ -10,7 +10,7 @@ april watches for GitHub issues assigned to you with a specific label, then spin
10
10
  - [gh](https://cli.github.com/) (authenticated)
11
11
  - The [`gh-webhook` extension](https://github.com/cli/gh-webhook): `gh extension install cli/gh-webhook`
12
12
  - [tmux](https://github.com/tmux/tmux)
13
- - [Claude Code](https://claude.ai/claude-code) CLI
13
+ - One of: [Claude Code](https://claude.ai/claude-code) CLI, or [Codex](https://developers.openai.com/codex/cli) CLI
14
14
 
15
15
  ## Quick install
16
16
 
@@ -31,7 +31,7 @@ The minimal flow above leaves out auth setup and a few server-specific gotchas.
31
31
  ### 1. System prerequisites
32
32
 
33
33
  ```bash
34
- # Install node 22+, tmux, gh, claude code via your usual route, then:
34
+ # Install node 22+, tmux, gh, and your chosen agent CLI (claude or codex) via your usual route, then:
35
35
  gh extension install cli/gh-webhook
36
36
  ```
37
37
 
@@ -44,13 +44,13 @@ Generate one on your GitHub host's web UI: Settings → Developer settings → P
44
44
  **Classic PAT scopes:**
45
45
  - `repo` — issue read/write, label updates, PR creation, code access (no smaller scope works for private repo issues)
46
46
  - `admin:repo_hook` — needed for `gh webhook forward` to register and clean up its temporary `cli` webhook
47
- - `workflow` — only if Claude might modify `.github/workflows/*` files
47
+ - `workflow` — only if the agent might modify `.github/workflows/*` files
48
48
 
49
49
  **Fine-grained PAT** (GHES 3.10+):
50
50
  - Repository access: select the repos
51
51
  - Permissions: Issues R/W, Pull requests R/W, Contents R/W, Webhooks R/W, Metadata R, Workflows R/W (last one optional)
52
52
 
53
- The daemon's own needs are smaller (Issues R/W + Webhooks R/W + Metadata R), but Claude inherits the same env when it runs inside tmux, so the token has to cover both — see [Token inheritance](#token-inheritance) below.
53
+ The daemon's own needs are smaller (Issues R/W + Webhooks R/W + Metadata R), but the agent inherits the same env when it runs inside tmux, so the token has to cover both — see [Token inheritance](#token-inheritance) below.
54
54
 
55
55
  ### 3. Install the package
56
56
 
@@ -112,7 +112,7 @@ Healthy logs include `Starting webhook forwarder for <repo>` and no immediate er
112
112
 
113
113
  | Command | What it does |
114
114
  | --- | --- |
115
- | `april init` | Copies the bundled `config.example.yaml` to `~/.config/april/config.yaml` and the `issue-worker` skill to `~/.claude/skills/`. **Only writes files that don't already exist** — never overwrites. |
115
+ | `april init` | Copies the bundled `config.example.yaml` to `~/.config/april/config.yaml` and the `issue-worker` skill to the configured agent's skill dir (`~/.claude/skills/` for claude, `~/.agents/skills/` for codex). **Only writes files that don't already exist** — never overwrites. |
116
116
  | `april install` | Installs and starts the user service. Pass `--print` to see the unit/plist without writing it. |
117
117
  | `april install-skill [-y]` | Install or refresh the issue-worker skill. Prompts before overwriting an existing copy; `--yes` skips the prompt (use in non-interactive scripts). |
118
118
  | `april upgrade [VER]` | Upgrade the npm package, regenerate the unit, restart the service, and reconcile the skill. |
@@ -142,13 +142,45 @@ After editing:
142
142
 
143
143
  ### Token inheritance
144
144
 
145
- `spawner.ts` runs Claude inside tmux with `tmux new-session -d <claudeCommand>` — no login shell. So Claude inherits the daemon's env directly, including any `GH_TOKEN` / `GH_ENTERPRISE_TOKEN` you set in the env file. Practical implication: the PAT you provide has to cover what *both* april and Claude need, not just april. If you want Claude to fall back to your shell's auth (a credential helper, network-level auth, etc.), you'd need to wrap the tmux command in a login shell — not currently supported.
145
+ `spawner.ts` runs the agent inside tmux with `tmux new-session -d <agentCommand>` — no login shell. So the agent inherits the daemon's env directly, including any `GH_TOKEN` / `GH_ENTERPRISE_TOKEN` you set in the env file. Practical implication: the PAT you provide has to cover what *both* april and the agent need, not just april. If you want the agent to fall back to your shell's auth (a credential helper, network-level auth, etc.), you'd need to wrap the tmux command in a login shell — not currently supported.
146
+
147
+ ## Choosing an agent
148
+
149
+ `llm` selects which CLI april spawns. `skill` is an april-level setting; CLI-specific options live under their own blocks:
150
+
151
+ ```yaml
152
+ llm: "claude" # or "codex"
153
+ skill: "issue-worker"
154
+
155
+ claude:
156
+ # model: "opus" # defaults to opus
157
+ # permissionMode: "auto" # defaults to auto
158
+
159
+ codex:
160
+ # model: "gpt-5.1-codex-max" # omit to use codex's configured default
161
+ # askForApproval: "never" # defaults to never
162
+ ```
163
+
164
+ | | claude | codex |
165
+ |---|---|---|
166
+ | Launch | `claude --model <model> --permission-mode <permissionMode>` | `codex --model <model> --ask-for-approval <askForApproval>` |
167
+ | Prompt sigil | `/issue-worker …` | `$issue-worker …` |
168
+ | Skill install dir | `~/.claude/skills/` | `~/.agents/skills/` |
169
+ | Default approval behavior | `permissionMode: "auto"` | `askForApproval: "never"` |
170
+
171
+ The bundled `issue-worker` skill is the same SKILL.md for both; april just installs it under the right tree based on `llm`. Switching agents later: edit `config.yaml`, then run `april install-skill && april restart`.
146
172
 
147
173
  ## Service backend
148
174
 
149
175
  - **Linux** uses systemd user services at `~/.config/systemd/user/april.service`. Logs go to the journal (`journalctl --user -u april`).
150
176
  - **macOS** uses launchd LaunchAgents at `~/Library/LaunchAgents/dev.april.daemon.plist`. Logs go to `~/Library/Logs/april/april.log`.
151
177
 
178
+ ### Restarts and tmux sessions
179
+
180
+ The unit/plist sets `KillMode=process` (systemd) / `AbandonProcessGroup=true` (launchd), which means `april restart` only signals the daemon itself — tmux sessions and the agent processes inside them keep running. The daemon's shutdown handler explicitly terminates the `gh webhook forward` children before exiting.
181
+
182
+ If the daemon ever has to be SIGKILLed (it hung past the systemd stop timeout), the forwarders will be orphaned to PID 1. Clean them up with `pkill -f 'gh webhook forward'`.
183
+
152
184
  ### Node version managers
153
185
 
154
186
  `april install` captures the absolute path of the `node` binary it was invoked with (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`) and bakes it into the unit/plist. If you later remove or change that node version, the service will fail to start — re-run `april install` after switching.
@@ -235,7 +267,7 @@ april restart
235
267
 
236
268
  Means your auth is provided by something other than a stored token (credential helper, network auth, wrapper). The webhook extension still needs an actual token — it doesn't matter how `gh` does its other API calls. Add `GH_TOKEN` / `GH_ENTERPRISE_TOKEN` to the env file.
237
269
 
238
- ### Service can't find `gh` / `tmux` / `claude` even though they're on your shell PATH
270
+ ### Service can't find `gh` / `tmux` / `claude` / `codex` even though they're on your shell PATH
239
271
 
240
272
  `april install` captures `$PATH` at install time and bakes it into the unit. If you installed any of those tools after running `april install`, re-run `april install` to recapture PATH.
241
273
 
@@ -245,8 +277,8 @@ Once installed, label a GitHub issue with `agent:todo` and assign it to yourself
245
277
 
246
278
  1. Create a git worktree for the issue
247
279
  2. Run any configured post-worktree hooks (e.g. `pnpm i`)
248
- 3. Spawn a tmux session with Claude Code
249
- 4. Claude reads the issue, implements a fix, and opens a PR
280
+ 3. Spawn a tmux session with the configured agent (claude or codex)
281
+ 4. The agent reads the issue, implements a fix, and opens a PR
250
282
  5. Issue labels transition: `agent:todo` → `agent:wip` → `agent:review`
251
283
 
252
284
  Attach to a running session anytime with `tmux attach -t <session-name>`.
@@ -1,8 +1,13 @@
1
1
  assignee: "your-github-username"
2
2
  label: "agent:todo"
3
- claudeSkill: "issue-worker"
4
- # claudeModel: "opus" # optional, defaults to opus
5
- # claudePermissionMode: "auto" # optional, defaults to auto (others: default, acceptEdits, plan, bypassPermissions)
3
+ llm: "claude" # "claude" or "codex"
4
+ skill: "issue-worker"
5
+ claude:
6
+ # model: "opus" # defaults to opus
7
+ # permissionMode: "auto" # defaults to auto
8
+ codex:
9
+ # model: "gpt-5.1-codex-max" # omit to use codex's configured default
10
+ # askForApproval: "never" # defaults to never; e.g. "on-request"
6
11
  port: 7890
7
12
  repos:
8
13
  - owner: "org"
package/dist/agents.js ADDED
@@ -0,0 +1,50 @@
1
+ import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+ function shellQuote(value) {
4
+ return `'${value.replace(/'/g, "'\\''")}'`;
5
+ }
6
+ const claude = {
7
+ kind: "claude",
8
+ cli: "claude",
9
+ buildCommand(config) {
10
+ const cfg = config.claude ?? {};
11
+ const model = cfg.model || "opus";
12
+ const permissionMode = cfg.permissionMode || "auto";
13
+ return `claude --model ${shellQuote(model)} --permission-mode ${shellQuote(permissionMode)}`;
14
+ },
15
+ buildPrompt(skill, body) {
16
+ return `/${skill} ${body}`;
17
+ },
18
+ skillDir(skill) {
19
+ return join(homedir(), ".claude", "skills", skill);
20
+ },
21
+ skillFile(skill) {
22
+ return join(this.skillDir(skill), "SKILL.md");
23
+ },
24
+ };
25
+ const codex = {
26
+ kind: "codex",
27
+ cli: "codex",
28
+ buildCommand(config) {
29
+ const cfg = config.codex ?? {};
30
+ const parts = ["codex"];
31
+ if (cfg.model)
32
+ parts.push("--model", shellQuote(cfg.model));
33
+ parts.push("--ask-for-approval", shellQuote(cfg.askForApproval || "never"));
34
+ return parts.join(" ");
35
+ },
36
+ buildPrompt(skill, body) {
37
+ return `$${skill} ${body}`;
38
+ },
39
+ skillDir(skill) {
40
+ return join(homedir(), ".agents", "skills", skill);
41
+ },
42
+ skillFile(skill) {
43
+ return join(this.skillDir(skill), "SKILL.md");
44
+ },
45
+ };
46
+ const AGENTS = { claude, codex };
47
+ export function getAgent(kind) {
48
+ return AGENTS[kind];
49
+ }
50
+ export const AGENT_KINDS = ["claude", "codex"];
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ Usage:
9
9
  april <command> [options]
10
10
 
11
11
  Commands:
12
- init Copy bundled config + skill to ~/.config/april and ~/.claude (only if missing).
12
+ init Copy bundled config, skill, and env file if missing.
13
13
  install [--print] Install and start the user service. --print emits the unit/plist to stdout instead.
14
14
  install-skill [-y] Install or refresh the issue-worker skill. Prompts before overwriting an existing
15
15
  one; --yes (-y) skips the prompt.
@@ -4,7 +4,7 @@ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { ensureEnvFile, envFilePath } from "../service/envfile.js";
6
6
  import { isGhWebhookExtensionInstalled, GH_EXTENSION_INSTALL_CMD } from "../precheck.js";
7
- import { SKILL_DST, bundledSkillPath, compareSkill } from "../skill.js";
7
+ import { skillDestPath, bundledSkillPath, compareSkill } from "../skill.js";
8
8
  // Resolve the bundled package root from this file's installed location.
9
9
  // dist/commands/init.js -> dist/.. (the package root, where config.example.yaml lives)
10
10
  function packageRoot() {
@@ -37,7 +37,11 @@ export function run(_args) {
37
37
  console.error(` Cannot find bundled skill at ${skillSrc}`);
38
38
  return 1;
39
39
  }
40
- copyIfMissing(skillSrc, SKILL_DST, "skill");
40
+ // Skill install path depends on the configured LLM. Before init writes
41
+ // a config, configuredLlmKind() falls back to claude, which matches the
42
+ // bundled example. If the user switches the agent later, they re-run
43
+ // `april install-skill`.
44
+ copyIfMissing(skillSrc, skillDestPath(), "skill");
41
45
  const envState = ensureEnvFile();
42
46
  console.log(` env: ${envState === "created" ? "wrote" : "already exists"} ${envFilePath()}`);
43
47
  console.log("");
@@ -2,7 +2,7 @@ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { stdin, stdout } from "node:process";
5
- import { SKILL_DST, bundledSkillPath, compareSkill } from "../skill.js";
5
+ import { skillDestPath, bundledSkillPath, compareSkill } from "../skill.js";
6
6
  export async function run(args) {
7
7
  const yes = args.includes("--yes") || args.includes("-y");
8
8
  const src = bundledSkillPath();
@@ -10,23 +10,24 @@ export async function run(args) {
10
10
  console.error(`Cannot find bundled skill at ${src}`);
11
11
  return 1;
12
12
  }
13
+ const dst = skillDestPath();
13
14
  const state = compareSkill();
14
15
  if (state === "matches-bundled") {
15
- console.log(`✓ Skill at ${SKILL_DST} is already up to date`);
16
+ console.log(`✓ Skill at ${dst} is already up to date`);
16
17
  return 0;
17
18
  }
18
19
  if (state === "missing") {
19
- mkdirSync(dirname(SKILL_DST), { recursive: true });
20
- copyFileSync(src, SKILL_DST);
21
- console.log(`✓ Installed skill at ${SKILL_DST}`);
20
+ mkdirSync(dirname(dst), { recursive: true });
21
+ copyFileSync(src, dst);
22
+ console.log(`✓ Installed skill at ${dst}`);
22
23
  return 0;
23
24
  }
24
25
  // differs-from-bundled
25
26
  console.log("Bundled issue-worker skill differs from the installed copy.");
26
- console.log(` installed: ${SKILL_DST}`);
27
+ console.log(` installed: ${dst}`);
27
28
  console.log(` bundled: ${src}`);
28
29
  console.log("");
29
- console.log(`Diff with: diff ${SKILL_DST} ${src}`);
30
+ console.log(`Diff with: diff ${dst} ${src}`);
30
31
  console.log("");
31
32
  if (!yes) {
32
33
  if (!stdin.isTTY) {
@@ -44,7 +45,7 @@ export async function run(args) {
44
45
  return 0;
45
46
  }
46
47
  }
47
- copyFileSync(src, SKILL_DST);
48
- console.log(`✓ Overwrote skill at ${SKILL_DST}`);
48
+ copyFileSync(src, dst);
49
+ console.log(`✓ Overwrote skill at ${dst}`);
49
50
  return 0;
50
51
  }
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
5
5
  import { parse as parseYaml } from "yaml";
6
6
  import { createLogger } from "./logger.js";
7
7
  import { isGhWebhookExtensionInstalled, GH_EXTENSION_INSTALL_CMD } from "./precheck.js";
8
+ import { AGENT_KINDS, getAgent } from "./agents.js";
8
9
  const log = createLogger("config");
9
10
  function findConfigPath() {
10
11
  const envPath = process.env.APRIL_CONFIG;
@@ -28,8 +29,8 @@ function findConfigPath() {
28
29
  ` - ${localPath}\n` +
29
30
  "Create a config.yaml (see config.example.yaml).");
30
31
  }
31
- function validateTools() {
32
- const tools = ["gh", "tmux", "git", "claude"];
32
+ function validateTools(agentCli) {
33
+ const tools = ["gh", "tmux", "git", agentCli];
33
34
  for (const tool of tools) {
34
35
  try {
35
36
  execSync(`which ${tool}`, { stdio: "pipe" });
@@ -50,20 +51,57 @@ function validateString(obj, key, context) {
50
51
  }
51
52
  return val.trim();
52
53
  }
53
- export function loadConfig() {
54
- validateTools();
55
- const configPath = findConfigPath();
56
- log.info(`Loading config from ${configPath}`);
57
- const raw = readFileSync(configPath, "utf-8");
54
+ function optionalObject(obj, key, context) {
55
+ const val = obj[key];
56
+ if (val === undefined || val === null)
57
+ return undefined;
58
+ if (typeof val !== "object" || Array.isArray(val)) {
59
+ throw new Error(`${context}: "${key}" must be an object when provided`);
60
+ }
61
+ return val;
62
+ }
63
+ function optionalString(obj, key) {
64
+ const val = obj[key];
65
+ return typeof val === "string" && val.trim().length > 0 ? val.trim() : undefined;
66
+ }
67
+ function parseLlm(parsed) {
68
+ const llmRaw = parsed.llm;
69
+ if (typeof llmRaw !== "string" || !AGENT_KINDS.includes(llmRaw)) {
70
+ throw new Error(`config: "llm" is required and must be one of ${AGENT_KINDS.join(", ")}`);
71
+ }
72
+ return llmRaw;
73
+ }
74
+ function parseClaudeConfig(parsed) {
75
+ const raw = optionalObject(parsed, "claude", "config");
76
+ if (!raw)
77
+ return undefined;
78
+ return {
79
+ model: optionalString(raw, "model"),
80
+ permissionMode: optionalString(raw, "permissionMode"),
81
+ };
82
+ }
83
+ function parseCodexConfig(parsed) {
84
+ const raw = optionalObject(parsed, "codex", "config");
85
+ if (!raw)
86
+ return undefined;
87
+ return {
88
+ model: optionalString(raw, "model"),
89
+ askForApproval: optionalString(raw, "askForApproval"),
90
+ };
91
+ }
92
+ export function parseConfigFile(path) {
93
+ const raw = readFileSync(path, "utf-8");
58
94
  const parsed = parseYaml(raw);
59
95
  if (!parsed || typeof parsed !== "object") {
60
96
  throw new Error("Config file is empty or not a valid YAML object");
61
97
  }
62
98
  const assignee = validateString(parsed, "assignee", "config");
63
99
  const label = validateString(parsed, "label", "config");
64
- const claudeSkill = validateString(parsed, "claudeSkill", "config");
65
- const claudeModel = typeof parsed.claudeModel === "string" ? parsed.claudeModel.trim() : undefined;
66
- const claudePermissionMode = typeof parsed.claudePermissionMode === "string" ? parsed.claudePermissionMode.trim() : undefined;
100
+ const root = parsed;
101
+ const llm = parseLlm(root);
102
+ const skill = validateString(root, "skill", "config");
103
+ const claude = parseClaudeConfig(root);
104
+ const codex = parseCodexConfig(root);
67
105
  const port = Number(parsed.port);
68
106
  if (!Number.isInteger(port) || port < 1024 || port > 65535) {
69
107
  throw new Error(`config: "port" must be an integer between 1024 and 65535, got: ${parsed.port}`);
@@ -94,7 +132,36 @@ export function loadConfig() {
94
132
  : undefined;
95
133
  return { owner, name, path: resolvedPath, defaultBranch, slackChannel, postWorktreeHook };
96
134
  });
97
- const config = { assignee, label, claudeSkill, claudeModel, claudePermissionMode, port, repos };
98
- log.info(`Config loaded: assignee=${assignee}, label=${label}, repos=${repos.map((r) => `${r.owner}/${r.name}`).join(", ")}`);
135
+ const config = { assignee, label, llm, skill, claude, codex, port, repos };
136
+ return config;
137
+ }
138
+ /**
139
+ * Best-effort lookup of the configured LLM kind, for commands that touch
140
+ * agent-specific paths (e.g. install-skill) but should still work before the
141
+ * user has written a config. Returns "claude" as a fallback.
142
+ */
143
+ export function configuredLlmKind() {
144
+ try {
145
+ const path = findConfigPath();
146
+ const raw = readFileSync(path, "utf-8");
147
+ const parsed = parseYaml(raw);
148
+ if (!parsed || typeof parsed !== "object")
149
+ return "claude";
150
+ const llm = parsed.llm;
151
+ return typeof llm === "string" && AGENT_KINDS.includes(llm)
152
+ ? llm
153
+ : "claude";
154
+ }
155
+ catch {
156
+ return "claude";
157
+ }
158
+ }
159
+ export function loadConfig() {
160
+ const configPath = findConfigPath();
161
+ log.info(`Loading config from ${configPath}`);
162
+ const config = parseConfigFile(configPath);
163
+ validateTools(getAgent(config.llm).cli);
164
+ log.info(`Config loaded: assignee=${config.assignee}, label=${config.label}, llm=${config.llm}, ` +
165
+ `repos=${config.repos.map((r) => `${r.owner}/${r.name}`).join(", ")}`);
99
166
  return config;
100
167
  }
@@ -58,6 +58,11 @@ ${envEntries}
58
58
  <string>${escapeXml(log)}</string>
59
59
  <key>ProcessType</key>
60
60
  <string>Background</string>
61
+ <!-- launchd's equivalent of systemd's KillMode=process: when the daemon
62
+ exits, don't kill children that share its process group. Keeps tmux
63
+ sessions and any in-flight agent work alive across restarts. -->
64
+ <key>AbandonProcessGroup</key>
65
+ <true/>
61
66
  </dict>
62
67
  </plist>
63
68
  `;
@@ -15,7 +15,7 @@ function runSystemctl(args) {
15
15
  export function unitContents() {
16
16
  const node = nodeBinaryPath();
17
17
  const entry = daemonEntryPath();
18
- // Capture caller's PATH so child has access to gh, tmux, git, claude.
18
+ // Capture caller's PATH so child has access to gh, tmux, git, and the configured agent CLI.
19
19
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
20
20
  return `[Unit]
21
21
  Description=april — issue worker
@@ -32,6 +32,10 @@ Environment=NODE_ENV=production
32
32
  EnvironmentFile=-${envFilePath()}
33
33
  StandardOutput=journal
34
34
  StandardError=journal
35
+ # Only signal the main process on stop; leave tmux sessions and any other
36
+ # children running so an april restart doesn't trash in-flight agent work.
37
+ # The daemon's own shutdown handler still SIGTERMs the gh webhook forwarders.
38
+ KillMode=process
35
39
 
36
40
  [Install]
37
41
  WantedBy=default.target
package/dist/skill.js CHANGED
@@ -1,20 +1,25 @@
1
1
  import { fileURLToPath } from "node:url";
2
- import { dirname, join, resolve } from "node:path";
2
+ import { dirname, resolve } from "node:path";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
- import { homedir } from "node:os";
5
- export const SKILL_DST = join(homedir(), ".claude", "skills", "issue-worker", "SKILL.md");
4
+ import { configuredLlmKind } from "./config.js";
5
+ import { getAgent } from "./agents.js";
6
+ const SKILL_NAME = "issue-worker";
6
7
  export function bundledSkillPath() {
7
8
  // dist/skill.js -> dist/.. -> package root, then skills/issue-worker/SKILL.md
8
9
  const here = fileURLToPath(import.meta.url);
9
- return resolve(dirname(here), "..", "skills", "issue-worker", "SKILL.md");
10
+ return resolve(dirname(here), "..", "skills", SKILL_NAME, "SKILL.md");
10
11
  }
11
- export function compareSkill() {
12
- if (!existsSync(SKILL_DST))
12
+ export function skillDestPath(kind = configuredLlmKind()) {
13
+ return getAgent(kind).skillFile(SKILL_NAME);
14
+ }
15
+ export function compareSkill(kind = configuredLlmKind()) {
16
+ const dst = skillDestPath(kind);
17
+ if (!existsSync(dst))
13
18
  return "missing";
14
19
  const src = bundledSkillPath();
15
20
  if (!existsSync(src))
16
21
  return "matches-bundled"; // can't compare; don't alarm
17
- return readFileSync(SKILL_DST).equals(readFileSync(src))
22
+ return readFileSync(dst).equals(readFileSync(src))
18
23
  ? "matches-bundled"
19
24
  : "differs-from-bundled";
20
25
  }
package/dist/spawner.js CHANGED
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { createLogger } from "./logger.js";
5
5
  import { makeSlug } from "./slug.js";
6
+ import { getAgent } from "./agents.js";
6
7
  const log = createLogger("spawner");
7
8
  function checkWorktreesIgnored(repoPath) {
8
9
  const gitignorePath = join(repoPath, ".gitignore");
@@ -138,7 +139,7 @@ export async function createWorktree(repo, branch) {
138
139
  }
139
140
  return worktreePath;
140
141
  }
141
- export function spawnClaude(config, repo, issue, worktreePath, sessionName) {
142
+ export function spawnAgent(config, repo, issue, worktreePath, sessionName) {
142
143
  // Check if session already exists
143
144
  try {
144
145
  execSync(`tmux has-session -t ${JSON.stringify(sessionName)}`, { stdio: "pipe" });
@@ -148,20 +149,20 @@ export function spawnClaude(config, repo, issue, worktreePath, sessionName) {
148
149
  catch {
149
150
  // Session does not exist, proceed
150
151
  }
151
- const model = config.claudeModel || "opus";
152
- const permissionMode = config.claudePermissionMode || "auto";
152
+ const agent = getAgent(config.llm);
153
153
  const slackPart = repo.slackChannel ? ` Post the PR to Slack channel #${repo.slackChannel}.` : "";
154
- const prompt = `/${config.claudeSkill} Read GitHub issue #${issue.number} on ${repo.owner}/${repo.name} using the gh CLI. Implement it and open a PR.${slackPart}`;
154
+ const promptBody = `Read GitHub issue #${issue.number} on ${repo.owner}/${repo.name} using the gh CLI. Implement it and open a PR.${slackPart}`;
155
+ const prompt = agent.buildPrompt(config.skill, promptBody);
155
156
  log.debug(`Prompt: ${prompt}`);
156
- const claudeCommand = `claude --model ${model} --permission-mode ${permissionMode}`;
157
- log.info(`Spawning tmux session "${sessionName}" with claude`);
158
- execSync(`tmux new-session -d -s ${JSON.stringify(sessionName)} -c ${JSON.stringify(worktreePath)} ${JSON.stringify(claudeCommand)}`);
159
- // Send the prompt via send-keys after Claude starts
157
+ const agentCommand = agent.buildCommand(config);
158
+ log.info(`Spawning tmux session "${sessionName}" with ${agent.kind}`);
159
+ execSync(`tmux new-session -d -s ${JSON.stringify(sessionName)} -c ${JSON.stringify(worktreePath)} ${JSON.stringify(agentCommand)}`);
160
+ // Send the prompt via send-keys after the agent starts
160
161
  const escapedPrompt = prompt.replace(/'/g, "'\\''");
161
162
  const session = JSON.stringify(sessionName);
162
163
  setTimeout(() => {
163
164
  try {
164
- // Send text first, then Enter after a short delay to ensure Claude's input is ready
165
+ // Send text first, then Enter after a short delay to ensure the agent's input is ready
165
166
  execSync(`tmux send-keys -t ${session} '${escapedPrompt}'`, { stdio: "pipe" });
166
167
  setTimeout(() => {
167
168
  try {
@@ -195,12 +196,12 @@ export async function handleNewIssue(repo, issue, config) {
195
196
  log.error(`Failed to create worktree for #${issue.number}: ${err instanceof Error ? err.message : String(err)}`);
196
197
  return;
197
198
  }
198
- // Spawn tmux + claude
199
+ // Spawn tmux + agent
199
200
  try {
200
- spawnClaude(config, repo, issue, worktreePath, slug);
201
+ spawnAgent(config, repo, issue, worktreePath, slug);
201
202
  }
202
203
  catch (err) {
203
- log.error(`Failed to spawn claude for #${issue.number}: ${err instanceof Error ? err.message : String(err)}`);
204
+ log.error(`Failed to spawn ${config.llm} for #${issue.number}: ${err instanceof Error ? err.message : String(err)}`);
204
205
  return;
205
206
  }
206
207
  // Apply label transition
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@armstrongnate/april",
3
- "version": "0.0.7",
4
- "description": "She does all the work so you don't have to. Watches GitHub issues and spawns Claude Code sessions to work them.",
3
+ "version": "0.1.0",
4
+ "description": "She does all the work so you don't have to. Watches GitHub issues and spawns coding-agent sessions (Claude Code or Codex) to work them.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "april": "./dist/cli.js"
@@ -51,6 +51,10 @@ If the prompt specifies a Slack channel, use the Slack MCP tool to post a messag
51
51
 
52
52
  After creating the PR, monitor it until all checks pass and all review feedback is addressed.
53
53
 
54
+ Note: the "claude review" CI check turns green as soon as Claude *posts* a review — it does
55
+ not indicate the review was clean. Always read the actual review comments before deciding
56
+ the PR is done.
57
+
54
58
  Loop:
55
59
  1. Sleep for 3 minutes (`sleep 180`)
56
60
  2. Check CI status: `gh pr checks {pr_number} --repo {owner}/{repo}`