@armstrongnate/april 0.1.1 → 0.1.3
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 +1 -1
- package/config.example.yaml +1 -0
- package/dist/config.js +14 -4
- package/dist/index.js +4 -3
- package/dist/processes.js +6 -1
- package/dist/server.js +2 -2
- package/dist/service/launchd.js +1 -1
- package/dist/service/systemd.js +3 -2
- package/dist/session/herdr-client.js +83 -0
- package/dist/session/herdr.js +145 -0
- package/dist/session/index.js +15 -0
- package/dist/session/tmux.js +57 -0
- package/dist/session/types.js +18 -0
- package/dist/spawner.js +28 -76
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ april watches for GitHub issues assigned to you with a specific label, then spin
|
|
|
9
9
|
- [Node.js](https://nodejs.org/) >= 22
|
|
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
|
-
- [tmux](https://github.com/tmux/tmux)
|
|
12
|
+
- A session manager: [tmux](https://github.com/tmux/tmux) (default), or [herdr](https://herdr.dev) if you set `sessionManager: herdr` in config
|
|
13
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
|
package/config.example.yaml
CHANGED
package/dist/config.js
CHANGED
|
@@ -29,8 +29,8 @@ function findConfigPath() {
|
|
|
29
29
|
` - ${localPath}\n` +
|
|
30
30
|
"Create a config.yaml (see config.example.yaml).");
|
|
31
31
|
}
|
|
32
|
-
function validateTools(agentCli) {
|
|
33
|
-
const tools = ["gh", "
|
|
32
|
+
function validateTools(agentCli, sessionManager) {
|
|
33
|
+
const tools = ["gh", "git", agentCli, sessionManager === "herdr" ? "herdr" : "tmux"];
|
|
34
34
|
for (const tool of tools) {
|
|
35
35
|
try {
|
|
36
36
|
execSync(`which ${tool}`, { stdio: "pipe" });
|
|
@@ -71,6 +71,15 @@ function parseLlm(parsed) {
|
|
|
71
71
|
}
|
|
72
72
|
return llmRaw;
|
|
73
73
|
}
|
|
74
|
+
function parseSessionManager(parsed) {
|
|
75
|
+
const val = parsed.sessionManager;
|
|
76
|
+
if (val === undefined || val === null)
|
|
77
|
+
return "tmux";
|
|
78
|
+
if (val !== "tmux" && val !== "herdr") {
|
|
79
|
+
throw new Error(`config: "sessionManager" must be "tmux" or "herdr" when provided`);
|
|
80
|
+
}
|
|
81
|
+
return val;
|
|
82
|
+
}
|
|
74
83
|
function parseClaudeConfig(parsed) {
|
|
75
84
|
const raw = optionalObject(parsed, "claude", "config");
|
|
76
85
|
if (!raw)
|
|
@@ -99,6 +108,7 @@ export function parseConfigFile(path) {
|
|
|
99
108
|
const label = validateString(parsed, "label", "config");
|
|
100
109
|
const root = parsed;
|
|
101
110
|
const llm = parseLlm(root);
|
|
111
|
+
const sessionManager = parseSessionManager(root);
|
|
102
112
|
const skill = validateString(root, "skill", "config");
|
|
103
113
|
const claude = parseClaudeConfig(root);
|
|
104
114
|
const codex = parseCodexConfig(root);
|
|
@@ -132,7 +142,7 @@ export function parseConfigFile(path) {
|
|
|
132
142
|
: undefined;
|
|
133
143
|
return { owner, name, path: resolvedPath, defaultBranch, slackChannel, postWorktreeHook };
|
|
134
144
|
});
|
|
135
|
-
const config = { assignee, label, llm, skill, claude, codex, port, repos };
|
|
145
|
+
const config = { assignee, label, llm, sessionManager, skill, claude, codex, port, repos };
|
|
136
146
|
return config;
|
|
137
147
|
}
|
|
138
148
|
/**
|
|
@@ -160,7 +170,7 @@ export function loadConfig() {
|
|
|
160
170
|
const configPath = findConfigPath();
|
|
161
171
|
log.info(`Loading config from ${configPath}`);
|
|
162
172
|
const config = parseConfigFile(configPath);
|
|
163
|
-
validateTools(getAgent(config.llm).cli);
|
|
173
|
+
validateTools(getAgent(config.llm).cli, config.sessionManager ?? "tmux");
|
|
164
174
|
log.info(`Config loaded: assignee=${config.assignee}, label=${config.label}, llm=${config.llm}, ` +
|
|
165
175
|
`repos=${config.repos.map((r) => `${r.owner}/${r.name}`).join(", ")}`);
|
|
166
176
|
return config;
|
package/dist/index.js
CHANGED
|
@@ -54,7 +54,7 @@ async function main() {
|
|
|
54
54
|
log.info(`Checking for missed issues in ${repo.owner}/${repo.name}...`);
|
|
55
55
|
const openIssues = fetchOpenIssues(repo, config);
|
|
56
56
|
for (const issue of openIssues) {
|
|
57
|
-
if (!isIssueActive(repo, issue.number)) {
|
|
57
|
+
if (!(await isIssueActive(repo, issue.number, config))) {
|
|
58
58
|
log.info(`Found missed issue: #${issue.number} — "${issue.title}"`);
|
|
59
59
|
await handleNewIssue(repo, issue, config);
|
|
60
60
|
}
|
|
@@ -76,12 +76,13 @@ async function main() {
|
|
|
76
76
|
// 5. Start webhook forwarders
|
|
77
77
|
const children = startWebhookForwarders(config);
|
|
78
78
|
// Print startup banner
|
|
79
|
-
const { worktrees, sessions } = getActiveCounts(config);
|
|
79
|
+
const { worktrees, sessions } = await getActiveCounts(config);
|
|
80
80
|
const repoList = config.repos.map((r) => `${r.owner}/${r.name}`).join(", ");
|
|
81
81
|
console.log(` Assignee: ${config.assignee}`);
|
|
82
82
|
console.log(` Label: ${config.label}`);
|
|
83
83
|
console.log(` Repos: ${repoList}`);
|
|
84
|
-
console.log(`
|
|
84
|
+
console.log(` Session manager: ${config.sessionManager ?? "tmux"}`);
|
|
85
|
+
console.log(` Active: ${worktrees} worktree${worktrees === 1 ? "" : "s"}, ${sessions} session${sessions === 1 ? "" : "s"}`);
|
|
85
86
|
console.log(` Server: http://localhost:${config.port}`);
|
|
86
87
|
console.log(` Forwarders: ${children.length} active`);
|
|
87
88
|
console.log("");
|
package/dist/processes.js
CHANGED
|
@@ -43,6 +43,12 @@ function spawnForwarder(config, repoKey, url) {
|
|
|
43
43
|
function start() {
|
|
44
44
|
if (state.stopped)
|
|
45
45
|
return;
|
|
46
|
+
// Clear any hook left behind by a previous attempt. `gh webhook forward`
|
|
47
|
+
// can create the `cli` hook and then die before/while opening its receiving
|
|
48
|
+
// websocket (e.g. a transient relay 500), leaving an orphan that makes every
|
|
49
|
+
// restart fail with HTTP 422 "Hook already exists". Cleaning up before each
|
|
50
|
+
// (re)start guarantees a clean slate instead of a permanent restart loop.
|
|
51
|
+
cleanupStaleWebhooks(repoKey);
|
|
46
52
|
state.lastStartTime = Date.now();
|
|
47
53
|
log.info(`Starting webhook forwarder for ${repoKey}`);
|
|
48
54
|
const child = spawn("gh", [
|
|
@@ -93,7 +99,6 @@ export function startWebhookForwarders(config) {
|
|
|
93
99
|
const url = `http://localhost:${config.port}/webhook/github`;
|
|
94
100
|
for (const repo of config.repos) {
|
|
95
101
|
const repoKey = `${repo.owner}/${repo.name}`;
|
|
96
|
-
cleanupStaleWebhooks(repoKey);
|
|
97
102
|
const state = spawnForwarder(config, repoKey, url);
|
|
98
103
|
forwarders.push(state);
|
|
99
104
|
}
|
package/dist/server.js
CHANGED
|
@@ -30,7 +30,7 @@ export async function startServer(config, onNewIssue) {
|
|
|
30
30
|
const result = parseWebhookEvent(headers, body, config);
|
|
31
31
|
if (result?.kind === "issue_assigned") {
|
|
32
32
|
const key = `issue:${result.repo.owner}/${result.repo.name}#${result.issue.number}`;
|
|
33
|
-
if (isIssueActive(result.repo, result.issue.number)) {
|
|
33
|
+
if (await isIssueActive(result.repo, result.issue.number, config)) {
|
|
34
34
|
log.debug(`Issue ${key} already active, ignoring webhook`);
|
|
35
35
|
}
|
|
36
36
|
else if (isRecentlyProcessed(key)) {
|
|
@@ -53,7 +53,7 @@ export async function startServer(config, onNewIssue) {
|
|
|
53
53
|
markProcessed(key);
|
|
54
54
|
log.info(`Processing webhook for ${key}`);
|
|
55
55
|
try {
|
|
56
|
-
handlePrClosed(result.repo, result.branch);
|
|
56
|
+
await handlePrClosed(result.repo, result.branch, config);
|
|
57
57
|
}
|
|
58
58
|
catch (err) {
|
|
59
59
|
log.error(`Error handling PR close: ${err instanceof Error ? err.message : String(err)}`);
|
package/dist/service/launchd.js
CHANGED
|
@@ -59,7 +59,7 @@ ${envEntries}
|
|
|
59
59
|
<key>ProcessType</key>
|
|
60
60
|
<string>Background</string>
|
|
61
61
|
<!-- launchd's equivalent of systemd's KillMode=process: when the daemon
|
|
62
|
-
exits, don't kill children that share its process group. Keeps
|
|
62
|
+
exits, don't kill children that share its process group. Keeps agent
|
|
63
63
|
sessions and any in-flight agent work alive across restarts. -->
|
|
64
64
|
<key>AbandonProcessGroup</key>
|
|
65
65
|
<true/>
|
package/dist/service/systemd.js
CHANGED
|
@@ -15,7 +15,8 @@ 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,
|
|
18
|
+
// Capture caller's PATH so child has access to gh, the session manager
|
|
19
|
+
// (tmux/herdr), git, and the configured agent CLI.
|
|
19
20
|
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
20
21
|
return `[Unit]
|
|
21
22
|
Description=april — issue worker
|
|
@@ -32,7 +33,7 @@ Environment=NODE_ENV=production
|
|
|
32
33
|
EnvironmentFile=-${envFilePath()}
|
|
33
34
|
StandardOutput=journal
|
|
34
35
|
StandardError=journal
|
|
35
|
-
# Only signal the main process on stop; leave
|
|
36
|
+
# Only signal the main process on stop; leave agent sessions and any other
|
|
36
37
|
# children running so an april restart doesn't trash in-flight agent work.
|
|
37
38
|
# The daemon's own shutdown handler still SIGTERMs the gh webhook forwarders.
|
|
38
39
|
KillMode=process
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { connect } from "node:net";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Minimal client for the herdr socket API.
|
|
6
|
+
*
|
|
7
|
+
* Transport: newline-delimited JSON over a Unix domain socket. Each request is
|
|
8
|
+
* `{ id, method, params }` on one line; the matching response is `{ id, result }`
|
|
9
|
+
* or `{ id, error: { code, message } }`. The server may also emit unrelated
|
|
10
|
+
* event lines (different/absent id), which we skip.
|
|
11
|
+
*
|
|
12
|
+
* Socket resolution mirrors herdr's own order:
|
|
13
|
+
* HERDR_SOCKET_PATH -> HERDR_SESSION (named) -> default.
|
|
14
|
+
*
|
|
15
|
+
* We open a fresh connection per request. april's call volume is low (a handful
|
|
16
|
+
* of calls per issue), so this trades a negligible amount of overhead for not
|
|
17
|
+
* having to manage a long-lived multiplexed connection or reconnect logic.
|
|
18
|
+
*/
|
|
19
|
+
function socketPath() {
|
|
20
|
+
const explicit = process.env.HERDR_SOCKET_PATH;
|
|
21
|
+
if (explicit)
|
|
22
|
+
return explicit;
|
|
23
|
+
const sessionName = process.env.HERDR_SESSION;
|
|
24
|
+
if (sessionName) {
|
|
25
|
+
return join(homedir(), ".config", "herdr", "sessions", sessionName, "herdr.sock");
|
|
26
|
+
}
|
|
27
|
+
return join(homedir(), ".config", "herdr", "herdr.sock");
|
|
28
|
+
}
|
|
29
|
+
let reqCounter = 0;
|
|
30
|
+
export function herdrRequest(method, params = {}, timeoutMs = 10_000) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const id = `april_${++reqCounter}`;
|
|
33
|
+
const sock = connect(socketPath());
|
|
34
|
+
let buf = "";
|
|
35
|
+
let settled = false;
|
|
36
|
+
const finish = (fn) => {
|
|
37
|
+
if (settled)
|
|
38
|
+
return;
|
|
39
|
+
settled = true;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
sock.end();
|
|
42
|
+
fn();
|
|
43
|
+
};
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
finish(() => reject(new Error(`herdr request "${method}" timed out after ${timeoutMs}ms`)));
|
|
46
|
+
}, timeoutMs);
|
|
47
|
+
sock.on("connect", () => {
|
|
48
|
+
sock.write(JSON.stringify({ id, method, params }) + "\n");
|
|
49
|
+
});
|
|
50
|
+
sock.on("data", (chunk) => {
|
|
51
|
+
buf += chunk.toString("utf-8");
|
|
52
|
+
let nl;
|
|
53
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
54
|
+
const line = buf.slice(0, nl).trim();
|
|
55
|
+
buf = buf.slice(nl + 1);
|
|
56
|
+
if (!line)
|
|
57
|
+
continue;
|
|
58
|
+
let msg;
|
|
59
|
+
try {
|
|
60
|
+
msg = JSON.parse(line);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
continue; // not a complete/valid JSON line; ignore
|
|
64
|
+
}
|
|
65
|
+
if (msg.id !== id)
|
|
66
|
+
continue; // unrelated event or response
|
|
67
|
+
if (msg.error) {
|
|
68
|
+
finish(() => reject(new Error(`herdr ${method}: ${msg.error.code} — ${msg.error.message}`)));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
finish(() => resolve(msg.result));
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
sock.on("error", (err) => {
|
|
77
|
+
finish(() => reject(new Error(`herdr socket error on "${method}": ${err.message}`)));
|
|
78
|
+
});
|
|
79
|
+
sock.on("close", () => {
|
|
80
|
+
finish(() => reject(new Error(`herdr connection closed before "${method}" responded`)));
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createLogger } from "../logger.js";
|
|
2
|
+
import { herdrRequest } from "./herdr-client.js";
|
|
3
|
+
const log = createLogger("session:herdr");
|
|
4
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
5
|
+
async function listWorkspaces() {
|
|
6
|
+
const res = await herdrRequest("workspace.list");
|
|
7
|
+
return res.workspaces ?? [];
|
|
8
|
+
}
|
|
9
|
+
async function findByLabel(name) {
|
|
10
|
+
return (await listWorkspaces()).find((w) => w.label === name);
|
|
11
|
+
}
|
|
12
|
+
async function listPaneIds(workspaceId) {
|
|
13
|
+
const res = await herdrRequest("pane.list", { workspace_id: workspaceId });
|
|
14
|
+
return (res.panes ?? []).map((p) => p.pane_id);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Best-effort wait until the freshly launched agent is accepting input.
|
|
18
|
+
*
|
|
19
|
+
* This is the herdr-native replacement for april's blind `setTimeout` dance:
|
|
20
|
+
* we poll the pane's agent_status and proceed as soon as it's recognized,
|
|
21
|
+
* falling back to a fixed wait if detection never settles.
|
|
22
|
+
*/
|
|
23
|
+
async function waitForAgentReady(paneId, attempts = 10, intervalMs = 1000) {
|
|
24
|
+
for (let i = 0; i < attempts; i++) {
|
|
25
|
+
try {
|
|
26
|
+
const status = await paneStatus(paneId);
|
|
27
|
+
if (status && status !== "unknown")
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// transient; keep polling
|
|
32
|
+
}
|
|
33
|
+
await delay(intervalMs);
|
|
34
|
+
}
|
|
35
|
+
log.warn(`Agent in pane ${paneId} never reported a known status; proceeding anyway`);
|
|
36
|
+
}
|
|
37
|
+
const SUBMIT_SETTLE_MS = 1000;
|
|
38
|
+
/** Read a pane's current agent status (undefined if unavailable). */
|
|
39
|
+
async function paneStatus(paneId) {
|
|
40
|
+
const res = await herdrRequest("pane.get", { pane_id: paneId });
|
|
41
|
+
return res.pane?.agent_status;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Wait for the agent to leave `idle` — confirmation that the Enter submitted
|
|
45
|
+
* and the agent picked up the prompt. `working`/`blocked`/`done` all mean the
|
|
46
|
+
* prompt was accepted; staying `idle` means it's still sitting in the input box.
|
|
47
|
+
*/
|
|
48
|
+
async function confirmSubmitted(paneId, attempts = 6, intervalMs = 500) {
|
|
49
|
+
for (let i = 0; i < attempts; i++) {
|
|
50
|
+
try {
|
|
51
|
+
const status = await paneStatus(paneId);
|
|
52
|
+
if (status && status !== "idle" && status !== "unknown")
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// transient; keep polling
|
|
57
|
+
}
|
|
58
|
+
await delay(intervalMs);
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
async function submit(paneId, text) {
|
|
63
|
+
// send_text writes the raw text; send_keys ["Enter"] submits it as a real
|
|
64
|
+
// key event. The settle delay between them is load-bearing — see the header
|
|
65
|
+
// note: without it the Enter is swallowed by Claude's paste burst and the
|
|
66
|
+
// prompt just sits in the input box (mirrors april's tmux text-then-C-m wait).
|
|
67
|
+
await herdrRequest("pane.send_text", { pane_id: paneId, text });
|
|
68
|
+
await delay(SUBMIT_SETTLE_MS);
|
|
69
|
+
await herdrRequest("pane.send_keys", { pane_id: paneId, keys: ["Enter"] });
|
|
70
|
+
// Confirm via herdr's own agent_status that the prompt was accepted. If the
|
|
71
|
+
// agent is still idle, the Enter didn't take — resend it once. Resending
|
|
72
|
+
// Enter on an already-submitted (empty) input box is a harmless no-op.
|
|
73
|
+
if (await confirmSubmitted(paneId))
|
|
74
|
+
return;
|
|
75
|
+
log.warn(`Agent in pane ${paneId} still idle after submit; resending Enter`);
|
|
76
|
+
await herdrRequest("pane.send_keys", { pane_id: paneId, keys: ["Enter"] });
|
|
77
|
+
}
|
|
78
|
+
export const herdrBackend = {
|
|
79
|
+
async listSessions() {
|
|
80
|
+
return (await listWorkspaces())
|
|
81
|
+
.map((w) => w.label)
|
|
82
|
+
.filter((l) => typeof l === "string" && l.length > 0);
|
|
83
|
+
},
|
|
84
|
+
async hasSession(name) {
|
|
85
|
+
return (await findByLabel(name)) !== undefined;
|
|
86
|
+
},
|
|
87
|
+
async spawn({ name, cwd, command, prompt }) {
|
|
88
|
+
if (await this.hasSession(name)) {
|
|
89
|
+
log.info(`herdr workspace "${name}" already exists, skipping`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Create a dedicated labeled workspace for this issue (the slug is our
|
|
93
|
+
// identity key for list/dedupe/kill). It also spawns a root shell pane.
|
|
94
|
+
const created = await herdrRequest("workspace.create", { cwd, label: name });
|
|
95
|
+
const workspaceId = created.workspace?.workspace_id;
|
|
96
|
+
if (!workspaceId) {
|
|
97
|
+
throw new Error(`herdr workspace.create for "${name}" returned no workspace id`);
|
|
98
|
+
}
|
|
99
|
+
// Launch the agent as the pane's own process via a non-interactive shell,
|
|
100
|
+
// placed explicitly in our workspace.
|
|
101
|
+
const started = await herdrRequest("agent.start", {
|
|
102
|
+
name,
|
|
103
|
+
cwd,
|
|
104
|
+
argv: ["sh", "-c", `exec ${command}`],
|
|
105
|
+
workspace_id: workspaceId,
|
|
106
|
+
});
|
|
107
|
+
let paneId = started.agent?.pane_id;
|
|
108
|
+
if (!paneId) {
|
|
109
|
+
throw new Error(`herdr agent.start for "${name}" returned no pane id`);
|
|
110
|
+
}
|
|
111
|
+
// Drop the idle root shell pane so the workspace holds only the agent.
|
|
112
|
+
// Closing it renumbers the remaining pane, so re-resolve the agent's pane.
|
|
113
|
+
const rootPaneId = created.root_pane?.pane_id;
|
|
114
|
+
if (rootPaneId) {
|
|
115
|
+
try {
|
|
116
|
+
await herdrRequest("pane.close", { pane_id: rootPaneId });
|
|
117
|
+
await delay(300);
|
|
118
|
+
const panes = await listPaneIds(workspaceId);
|
|
119
|
+
if (panes.length === 1) {
|
|
120
|
+
paneId = panes[0];
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
log.warn(`Expected 1 pane after closing root shell in "${name}", found ${panes.length}; keeping ${paneId}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
log.warn(`Could not close root shell pane in "${name}": ${err instanceof Error ? err.message : String(err)}; leaving it`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
log.info(`Started agent in herdr workspace "${name}" (pane ${paneId})`);
|
|
131
|
+
// Wait until the agent is up, then inject the prompt.
|
|
132
|
+
await waitForAgentReady(paneId);
|
|
133
|
+
await submit(paneId, prompt);
|
|
134
|
+
log.info(`Prompt sent to herdr workspace "${name}"`);
|
|
135
|
+
},
|
|
136
|
+
async kill(name) {
|
|
137
|
+
const ws = await findByLabel(name);
|
|
138
|
+
if (!ws) {
|
|
139
|
+
log.debug(`No herdr workspace labeled "${name}"; nothing to kill`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
await herdrRequest("workspace.close", { workspace_id: ws.workspace_id });
|
|
143
|
+
log.info(`Closed herdr workspace "${name}"`);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { tmuxBackend } from "./tmux.js";
|
|
2
|
+
import { herdrBackend } from "./herdr.js";
|
|
3
|
+
/**
|
|
4
|
+
* Pick the session backend for this install. Defaults to tmux so existing
|
|
5
|
+
* configs keep working unchanged.
|
|
6
|
+
*/
|
|
7
|
+
export function getSessionBackend(config) {
|
|
8
|
+
switch (config.sessionManager) {
|
|
9
|
+
case "herdr":
|
|
10
|
+
return herdrBackend;
|
|
11
|
+
case "tmux":
|
|
12
|
+
default:
|
|
13
|
+
return tmuxBackend;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { createLogger } from "../logger.js";
|
|
3
|
+
const log = createLogger("session:tmux");
|
|
4
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
5
|
+
/**
|
|
6
|
+
* tmux backend. This is april's current behavior, lifted verbatim out of
|
|
7
|
+
* spawner.ts and behind the SessionBackend contract. Default backend, so
|
|
8
|
+
* existing installs are unaffected.
|
|
9
|
+
*/
|
|
10
|
+
export const tmuxBackend = {
|
|
11
|
+
async listSessions() {
|
|
12
|
+
try {
|
|
13
|
+
const output = execSync("tmux list-sessions -F '#{session_name}'", {
|
|
14
|
+
encoding: "utf-8",
|
|
15
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
16
|
+
});
|
|
17
|
+
return output.trim().split("\n").filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// tmux not running or no sessions
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
async hasSession(name) {
|
|
25
|
+
try {
|
|
26
|
+
execSync(`tmux has-session -t ${JSON.stringify(name)}`, { stdio: "pipe" });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
async spawn({ name, cwd, command, prompt }) {
|
|
34
|
+
if (await this.hasSession(name)) {
|
|
35
|
+
log.info(`tmux session "${name}" already exists, skipping`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
execSync(`tmux new-session -d -s ${JSON.stringify(name)} -c ${JSON.stringify(cwd)} ${JSON.stringify(command)}`);
|
|
39
|
+
// Send text first, then Enter after a short delay so the agent's input is ready.
|
|
40
|
+
const escapedPrompt = prompt.replace(/'/g, "'\\''");
|
|
41
|
+
const session = JSON.stringify(name);
|
|
42
|
+
await delay(3000);
|
|
43
|
+
execSync(`tmux send-keys -t ${session} '${escapedPrompt}'`, { stdio: "pipe" });
|
|
44
|
+
await delay(1000);
|
|
45
|
+
execSync(`tmux send-keys -t ${session} C-m`, { stdio: "pipe" });
|
|
46
|
+
log.info(`Prompt sent to tmux session "${name}"`);
|
|
47
|
+
},
|
|
48
|
+
async kill(name) {
|
|
49
|
+
try {
|
|
50
|
+
execSync(`tmux kill-session -t ${JSON.stringify(name)}`, { stdio: "pipe" });
|
|
51
|
+
log.info(`Killed tmux session ${name}`);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Session already gone — fine
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session backend abstraction.
|
|
3
|
+
*
|
|
4
|
+
* april needs only four primitives from whatever runs its agent sessions.
|
|
5
|
+
* Today those map to tmux; this interface lets a herdr (or any other) backend
|
|
6
|
+
* drop in behind the same contract.
|
|
7
|
+
*
|
|
8
|
+
* listSessions -> tmux `list-sessions` / herdr `workspace.list`
|
|
9
|
+
* hasSession -> tmux `has-session` / herdr `workspace.list` + filter
|
|
10
|
+
* spawn -> tmux `new-session` + send / herdr `workspace.create` + `pane.send_*`
|
|
11
|
+
* kill -> tmux `kill-session` / herdr `workspace.close`
|
|
12
|
+
*
|
|
13
|
+
* "name" is the stable identity april assigns — the branch slug, e.g.
|
|
14
|
+
* `gh-123-fix-foo`. tmux uses it directly as the session name. herdr assigns
|
|
15
|
+
* its own ids, so the backend stores the slug as the workspace `label` and
|
|
16
|
+
* resolves label -> id on demand.
|
|
17
|
+
*/
|
|
18
|
+
export {};
|
package/dist/spawner.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { createLogger } from "./logger.js";
|
|
5
5
|
import { makeSlug } from "./slug.js";
|
|
6
6
|
import { getAgent } from "./agents.js";
|
|
7
|
+
import { getSessionBackend } from "./session/index.js";
|
|
7
8
|
const log = createLogger("spawner");
|
|
8
9
|
function checkWorktreesIgnored(repoPath) {
|
|
9
10
|
const gitignorePath = join(repoPath, ".gitignore");
|
|
@@ -23,10 +24,11 @@ function checkWorktreesIgnored(repoPath) {
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
/**
|
|
26
|
-
* Check if an issue is already active by looking at the filesystem and
|
|
27
|
-
* Matches any worktree dir or
|
|
27
|
+
* Check if an issue is already active by looking at the filesystem and the
|
|
28
|
+
* session backend. Matches any worktree dir or session starting with
|
|
29
|
+
* `gh-{issueNumber}-`.
|
|
28
30
|
*/
|
|
29
|
-
export function isIssueActive(repo, issueNumber) {
|
|
31
|
+
export async function isIssueActive(repo, issueNumber, config) {
|
|
30
32
|
const prefix = `gh-${issueNumber}-`;
|
|
31
33
|
// Check worktrees on disk
|
|
32
34
|
const worktreesDir = join(repo.path, ".worktrees");
|
|
@@ -38,27 +40,19 @@ export function isIssueActive(repo, issueNumber) {
|
|
|
38
40
|
return true;
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
|
-
// Check
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const match = output.trim().split("\n").find((s) => s.startsWith(prefix));
|
|
48
|
-
if (match) {
|
|
49
|
-
log.info(`Skipping issue #${issueNumber}: existing tmux session found (${match})`);
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
// tmux not running or no sessions
|
|
43
|
+
// Check active sessions
|
|
44
|
+
const sessions = await getSessionBackend(config).listSessions();
|
|
45
|
+
const match = sessions.find((s) => s.startsWith(prefix));
|
|
46
|
+
if (match) {
|
|
47
|
+
log.info(`Skipping issue #${issueNumber}: existing session found (${match})`);
|
|
48
|
+
return true;
|
|
55
49
|
}
|
|
56
50
|
return false;
|
|
57
51
|
}
|
|
58
52
|
/**
|
|
59
|
-
* Count active worktrees and
|
|
53
|
+
* Count active worktrees and sessions across all configured repos.
|
|
60
54
|
*/
|
|
61
|
-
export function getActiveCounts(config) {
|
|
55
|
+
export async function getActiveCounts(config) {
|
|
62
56
|
let worktrees = 0;
|
|
63
57
|
for (const repo of config.repos) {
|
|
64
58
|
const dir = join(repo.path, ".worktrees");
|
|
@@ -66,17 +60,8 @@ export function getActiveCounts(config) {
|
|
|
66
60
|
worktrees += readdirSync(dir).filter((d) => d.startsWith("gh-")).length;
|
|
67
61
|
}
|
|
68
62
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const output = execSync("tmux list-sessions -F '#{session_name}'", {
|
|
72
|
-
encoding: "utf-8",
|
|
73
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
74
|
-
});
|
|
75
|
-
sessions = output.trim().split("\n").filter((s) => s.startsWith("gh-")).length;
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
// tmux not running
|
|
79
|
-
}
|
|
63
|
+
const sessionNames = await getSessionBackend(config).listSessions();
|
|
64
|
+
const sessions = sessionNames.filter((s) => s.startsWith("gh-")).length;
|
|
80
65
|
return { worktrees, sessions };
|
|
81
66
|
}
|
|
82
67
|
export async function createWorktree(repo, branch) {
|
|
@@ -139,50 +124,23 @@ export async function createWorktree(repo, branch) {
|
|
|
139
124
|
}
|
|
140
125
|
return worktreePath;
|
|
141
126
|
}
|
|
142
|
-
export function spawnAgent(config, repo, issue, worktreePath, sessionName) {
|
|
143
|
-
|
|
144
|
-
try {
|
|
145
|
-
execSync(`tmux has-session -t ${JSON.stringify(sessionName)}`, { stdio: "pipe" });
|
|
146
|
-
log.info(`tmux session "${sessionName}" already exists, skipping`);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
catch {
|
|
150
|
-
// Session does not exist, proceed
|
|
151
|
-
}
|
|
127
|
+
export async function spawnAgent(config, repo, issue, worktreePath, sessionName) {
|
|
128
|
+
const backend = getSessionBackend(config);
|
|
152
129
|
const agent = getAgent(config.llm);
|
|
153
130
|
const slackPart = repo.slackChannel ? ` Post the PR to Slack channel #${repo.slackChannel}.` : "";
|
|
154
131
|
const promptBody = `Read GitHub issue #${issue.number} on ${repo.owner}/${repo.name} using the gh CLI. Implement it and open a PR.${slackPart}`;
|
|
155
132
|
const prompt = agent.buildPrompt(config.skill, promptBody);
|
|
156
133
|
log.debug(`Prompt: ${prompt}`);
|
|
157
134
|
const agentCommand = agent.buildCommand(config);
|
|
158
|
-
log.info(`Spawning
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
setTimeout(() => {
|
|
164
|
-
try {
|
|
165
|
-
// Send text first, then Enter after a short delay to ensure the agent's input is ready
|
|
166
|
-
execSync(`tmux send-keys -t ${session} '${escapedPrompt}'`, { stdio: "pipe" });
|
|
167
|
-
setTimeout(() => {
|
|
168
|
-
try {
|
|
169
|
-
execSync(`tmux send-keys -t ${session} C-m`, { stdio: "pipe" });
|
|
170
|
-
log.info(`Prompt sent to tmux session "${sessionName}"`);
|
|
171
|
-
}
|
|
172
|
-
catch (err) {
|
|
173
|
-
log.warn(`Failed to send Enter to session "${sessionName}": ${err instanceof Error ? err.message : String(err)}`);
|
|
174
|
-
}
|
|
175
|
-
}, 1000);
|
|
176
|
-
}
|
|
177
|
-
catch (err) {
|
|
178
|
-
log.warn(`Failed to send prompt to session "${sessionName}": ${err instanceof Error ? err.message : String(err)}`);
|
|
179
|
-
}
|
|
180
|
-
}, 3000);
|
|
181
|
-
log.info(`tmux session "${sessionName}" started`);
|
|
135
|
+
log.info(`Spawning session "${sessionName}" with ${agent.kind}`);
|
|
136
|
+
// The backend is responsible for the dedupe check, launching the agent in the
|
|
137
|
+
// worktree, and injecting the prompt once it's ready.
|
|
138
|
+
await backend.spawn({ name: sessionName, cwd: worktreePath, command: agentCommand, prompt });
|
|
139
|
+
log.info(`Session "${sessionName}" started`);
|
|
182
140
|
}
|
|
183
141
|
export async function handleNewIssue(repo, issue, config) {
|
|
184
|
-
// Check if already active via filesystem/
|
|
185
|
-
if (isIssueActive(repo, issue.number)) {
|
|
142
|
+
// Check if already active via filesystem/session backend
|
|
143
|
+
if (await isIssueActive(repo, issue.number, config)) {
|
|
186
144
|
log.info(`Issue #${issue.number} already active in ${repo.owner}/${repo.name}, skipping`);
|
|
187
145
|
return;
|
|
188
146
|
}
|
|
@@ -196,9 +154,9 @@ export async function handleNewIssue(repo, issue, config) {
|
|
|
196
154
|
log.error(`Failed to create worktree for #${issue.number}: ${err instanceof Error ? err.message : String(err)}`);
|
|
197
155
|
return;
|
|
198
156
|
}
|
|
199
|
-
// Spawn
|
|
157
|
+
// Spawn session + agent
|
|
200
158
|
try {
|
|
201
|
-
spawnAgent(config, repo, issue, worktreePath, slug);
|
|
159
|
+
await spawnAgent(config, repo, issue, worktreePath, slug);
|
|
202
160
|
}
|
|
203
161
|
catch (err) {
|
|
204
162
|
log.error(`Failed to spawn ${config.llm} for #${issue.number}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -219,7 +177,7 @@ export async function handleNewIssue(repo, issue, config) {
|
|
|
219
177
|
}
|
|
220
178
|
log.info(`Issue #${issue.number} (${repo.owner}/${repo.name}) is now active`);
|
|
221
179
|
}
|
|
222
|
-
export function handlePrClosed(repo, branch) {
|
|
180
|
+
export async function handlePrClosed(repo, branch, config) {
|
|
223
181
|
log.info(`Cleaning up worktree for branch ${branch} (${repo.owner}/${repo.name})`);
|
|
224
182
|
try {
|
|
225
183
|
execFileSync("wt", ["remove", branch, "-f", "-D"], {
|
|
@@ -232,13 +190,7 @@ export function handlePrClosed(repo, branch) {
|
|
|
232
190
|
catch (err) {
|
|
233
191
|
log.warn(`wt remove ${branch} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
234
192
|
}
|
|
235
|
-
|
|
236
|
-
execFileSync("tmux", ["kill-session", "-t", branch], { stdio: "pipe" });
|
|
237
|
-
log.info(`Killed tmux session ${branch}`);
|
|
238
|
-
}
|
|
239
|
-
catch {
|
|
240
|
-
// Session already gone — fine
|
|
241
|
-
}
|
|
193
|
+
await getSessionBackend(config).kill(branch);
|
|
242
194
|
log.info(`Cleanup complete for ${branch}`);
|
|
243
195
|
}
|
|
244
196
|
export function fetchOpenIssues(repo, config) {
|
package/package.json
CHANGED