@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 +42 -10
- package/config.example.yaml +8 -3
- package/dist/agents.js +50 -0
- package/dist/cli.js +1 -1
- package/dist/commands/init.js +6 -2
- package/dist/commands/install-skill.js +10 -9
- package/dist/config.js +79 -12
- package/dist/service/launchd.js +5 -0
- package/dist/service/systemd.js +5 -1
- package/dist/skill.js +12 -7
- package/dist/spawner.js +13 -12
- package/package.json +2 -2
- package/skills/issue-worker/SKILL.md +4 -0
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
249
|
-
4.
|
|
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>`.
|
package/config.example.yaml
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
assignee: "your-github-username"
|
|
2
2
|
label: "agent:todo"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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.
|
package/dist/commands/init.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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 ${
|
|
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(
|
|
20
|
-
copyFileSync(src,
|
|
21
|
-
console.log(`✓ Installed skill at ${
|
|
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: ${
|
|
27
|
+
console.log(` installed: ${dst}`);
|
|
27
28
|
console.log(` bundled: ${src}`);
|
|
28
29
|
console.log("");
|
|
29
|
-
console.log(`Diff with: diff ${
|
|
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,
|
|
48
|
-
console.log(`✓ Overwrote skill at ${
|
|
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",
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
65
|
-
const
|
|
66
|
-
const
|
|
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,
|
|
98
|
-
|
|
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
|
}
|
package/dist/service/launchd.js
CHANGED
|
@@ -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
|
`;
|
package/dist/service/systemd.js
CHANGED
|
@@ -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,
|
|
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,
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
-
import {
|
|
5
|
-
|
|
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",
|
|
10
|
+
return resolve(dirname(here), "..", "skills", SKILL_NAME, "SKILL.md");
|
|
10
11
|
}
|
|
11
|
-
export function
|
|
12
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
157
|
-
log.info(`Spawning tmux session "${sessionName}" with
|
|
158
|
-
execSync(`tmux new-session -d -s ${JSON.stringify(sessionName)} -c ${JSON.stringify(worktreePath)} ${JSON.stringify(
|
|
159
|
-
// Send the prompt via send-keys after
|
|
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
|
|
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 +
|
|
199
|
+
// Spawn tmux + agent
|
|
199
200
|
try {
|
|
200
|
-
|
|
201
|
+
spawnAgent(config, repo, issue, worktreePath, slug);
|
|
201
202
|
}
|
|
202
203
|
catch (err) {
|
|
203
|
-
log.error(`Failed to spawn
|
|
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
|
|
4
|
-
"description": "She does all the work so you don't have to. Watches GitHub issues and spawns Claude Code
|
|
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}`
|