@armstrongnate/april 0.1.2 → 0.1.4

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
@@ -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
@@ -1,6 +1,7 @@
1
1
  assignee: "your-github-username"
2
2
  label: "agent:todo"
3
3
  llm: "claude" # "claude" or "codex"
4
+ sessionManager: "tmux" # "tmux" (default) or "herdr"
4
5
  skill: "issue-worker"
5
6
  claude:
6
7
  # model: "opus" # defaults to opus
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", "tmux", "git", agentCli];
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(` Active: ${worktrees} worktree${worktrees === 1 ? "" : "s"}, ${sessions} tmux session${sessions === 1 ? "" : "s"}`);
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/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)}`);
@@ -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 tmux
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/>
@@ -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, tmux, git, and the configured agent CLI.
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 tmux sessions and any other
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,215 @@
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
+ const INJECT_ATTEMPTS = 4;
39
+ /** Read a pane's current agent status (undefined if unavailable). */
40
+ async function paneStatus(paneId) {
41
+ const res = await herdrRequest("pane.get", { pane_id: paneId });
42
+ return res.pane?.agent_status;
43
+ }
44
+ /** A status that means the agent left the input box and picked up the prompt. */
45
+ function isAccepted(status) {
46
+ return status !== undefined && status !== "idle" && status !== "unknown";
47
+ }
48
+ const normalizeWhitespace = (text) => text.replace(/\s+/g, " ");
49
+ /**
50
+ * A short, wrap-safe slice of the prompt to look for in the pane's input box.
51
+ * Kept brief (and whitespace-normalized) so terminal wrapping or the `❯ `
52
+ * prefix don't break the substring match against what pane.read returns.
53
+ */
54
+ function promptSignature(prompt) {
55
+ return normalizeWhitespace(prompt).trim().slice(0, 24);
56
+ }
57
+ /** Visible (on-screen) text of a pane; "" if it can't be read. */
58
+ async function readVisible(paneId) {
59
+ try {
60
+ const res = await herdrRequest("pane.read", {
61
+ pane_id: paneId,
62
+ source: "visible",
63
+ });
64
+ return res.read?.text ?? "";
65
+ }
66
+ catch {
67
+ // transient; treat as "can't confirm"
68
+ return "";
69
+ }
70
+ }
71
+ /** Whether our prompt text currently appears in the pane (i.e. the input box). */
72
+ async function promptIsVisible(paneId, signature) {
73
+ if (!signature)
74
+ return true;
75
+ return normalizeWhitespace(await readVisible(paneId)).includes(signature);
76
+ }
77
+ /**
78
+ * Wait for the agent to leave `idle` — confirmation that the Enter submitted
79
+ * and the agent picked up the prompt. `working`/`blocked`/`done` all mean the
80
+ * prompt was accepted; staying `idle` means it's still sitting in the input box.
81
+ */
82
+ async function confirmSubmitted(paneId, attempts = 6, intervalMs = 500) {
83
+ for (let i = 0; i < attempts; i++) {
84
+ try {
85
+ if (isAccepted(await paneStatus(paneId)))
86
+ return true;
87
+ }
88
+ catch {
89
+ // transient; keep polling
90
+ }
91
+ await delay(intervalMs);
92
+ }
93
+ return false;
94
+ }
95
+ /**
96
+ * Inject the prompt and verify the agent actually picked it up, retrying around
97
+ * the two misfires documented in the header. Returns the agent status when we
98
+ * stop so the caller can log the outcome.
99
+ *
100
+ * The loop is self-correcting: each pass first checks whether the agent is
101
+ * already working (a prior Enter landed even if its confirm timed out — never
102
+ * retype into a running agent), then ensures the text is in the box (typing it
103
+ * only if absent, so retries can't duplicate it), then submits and confirms.
104
+ */
105
+ async function injectPrompt(paneId, prompt) {
106
+ const signature = promptSignature(prompt);
107
+ for (let attempt = 1; attempt <= INJECT_ATTEMPTS; attempt++) {
108
+ const status = await paneStatus(paneId).catch(() => undefined);
109
+ if (isAccepted(status)) {
110
+ if (attempt > 1)
111
+ log.info(`Agent in pane ${paneId} accepted the prompt (status: ${status})`);
112
+ return status;
113
+ }
114
+ // 1. Ensure our text is in the input box — type it only if it isn't there,
115
+ // so a retry after a failed submit doesn't append a second copy.
116
+ if (!(await promptIsVisible(paneId, signature))) {
117
+ await herdrRequest("pane.send_text", { pane_id: paneId, text: prompt });
118
+ await delay(SUBMIT_SETTLE_MS);
119
+ }
120
+ // 2. Still not there → a Claude startup screen ate the burst. Dismiss it
121
+ // with Enter and loop to retype. (That Enter may instead have submitted
122
+ // real text on a flaky read, so re-check status before retrying.)
123
+ if (!(await promptIsVisible(paneId, signature))) {
124
+ log.warn(`Prompt text not visible in pane ${paneId} input (attempt ${attempt}/${INJECT_ATTEMPTS}); dismissing startup screen and retrying`);
125
+ await herdrRequest("pane.send_keys", { pane_id: paneId, keys: ["Enter"] });
126
+ await delay(SUBMIT_SETTLE_MS);
127
+ const dismissed = await paneStatus(paneId).catch(() => undefined);
128
+ if (isAccepted(dismissed))
129
+ return dismissed;
130
+ continue;
131
+ }
132
+ // 3. Text is in the box — submit it and confirm via agent_status.
133
+ await herdrRequest("pane.send_keys", { pane_id: paneId, keys: ["Enter"] });
134
+ if (await confirmSubmitted(paneId)) {
135
+ const accepted = await paneStatus(paneId).catch(() => undefined);
136
+ log.info(`Prompt submitted to pane ${paneId} on attempt ${attempt} (status: ${accepted ?? "unknown"})`);
137
+ return accepted;
138
+ }
139
+ log.warn(`Agent in pane ${paneId} still idle after Enter (attempt ${attempt}/${INJECT_ATTEMPTS}); will retry`);
140
+ }
141
+ return paneStatus(paneId).catch(() => undefined);
142
+ }
143
+ export const herdrBackend = {
144
+ async listSessions() {
145
+ return (await listWorkspaces())
146
+ .map((w) => w.label)
147
+ .filter((l) => typeof l === "string" && l.length > 0);
148
+ },
149
+ async hasSession(name) {
150
+ return (await findByLabel(name)) !== undefined;
151
+ },
152
+ async spawn({ name, cwd, command, prompt }) {
153
+ if (await this.hasSession(name)) {
154
+ log.info(`herdr workspace "${name}" already exists, skipping`);
155
+ return;
156
+ }
157
+ // Create a dedicated labeled workspace for this issue (the slug is our
158
+ // identity key for list/dedupe/kill). It also spawns a root shell pane.
159
+ const created = await herdrRequest("workspace.create", { cwd, label: name });
160
+ const workspaceId = created.workspace?.workspace_id;
161
+ if (!workspaceId) {
162
+ throw new Error(`herdr workspace.create for "${name}" returned no workspace id`);
163
+ }
164
+ // Launch the agent as the pane's own process via a non-interactive shell,
165
+ // placed explicitly in our workspace.
166
+ const started = await herdrRequest("agent.start", {
167
+ name,
168
+ cwd,
169
+ argv: ["sh", "-c", `exec ${command}`],
170
+ workspace_id: workspaceId,
171
+ });
172
+ let paneId = started.agent?.pane_id;
173
+ if (!paneId) {
174
+ throw new Error(`herdr agent.start for "${name}" returned no pane id`);
175
+ }
176
+ // Drop the idle root shell pane so the workspace holds only the agent.
177
+ // Closing it renumbers the remaining pane, so re-resolve the agent's pane.
178
+ const rootPaneId = created.root_pane?.pane_id;
179
+ if (rootPaneId) {
180
+ try {
181
+ await herdrRequest("pane.close", { pane_id: rootPaneId });
182
+ await delay(300);
183
+ const panes = await listPaneIds(workspaceId);
184
+ if (panes.length === 1) {
185
+ paneId = panes[0];
186
+ }
187
+ else {
188
+ log.warn(`Expected 1 pane after closing root shell in "${name}", found ${panes.length}; keeping ${paneId}`);
189
+ }
190
+ }
191
+ catch (err) {
192
+ log.warn(`Could not close root shell pane in "${name}": ${err instanceof Error ? err.message : String(err)}; leaving it`);
193
+ }
194
+ }
195
+ log.info(`Started agent in herdr workspace "${name}" (pane ${paneId})`);
196
+ // Wait until the agent is up, then inject the prompt and verify it landed.
197
+ await waitForAgentReady(paneId);
198
+ const status = await injectPrompt(paneId, prompt);
199
+ if (isAccepted(status)) {
200
+ log.info(`Prompt delivered to herdr workspace "${name}" — agent is ${status} (pane ${paneId})`);
201
+ }
202
+ else {
203
+ log.error(`Prompt may NOT have been accepted in herdr workspace "${name}" — agent status "${status ?? "unavailable"}" after ${INJECT_ATTEMPTS} attempts (pane ${paneId}). The session likely needs manual attention.`);
204
+ }
205
+ },
206
+ async kill(name) {
207
+ const ws = await findByLabel(name);
208
+ if (!ws) {
209
+ log.debug(`No herdr workspace labeled "${name}"; nothing to kill`);
210
+ return;
211
+ }
212
+ await herdrRequest("workspace.close", { workspace_id: ws.workspace_id });
213
+ log.info(`Closed herdr workspace "${name}"`);
214
+ },
215
+ };
@@ -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 tmux.
27
- * Matches any worktree dir or tmux session starting with `gh-{issueNumber}-`.
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 tmux sessions
42
- try {
43
- const output = execSync("tmux list-sessions -F '#{session_name}'", {
44
- encoding: "utf-8",
45
- stdio: ["pipe", "pipe", "pipe"],
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 tmux sessions across all configured repos.
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
- let sessions = 0;
70
- try {
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
- // Check if session already exists
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 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
161
- const escapedPrompt = prompt.replace(/'/g, "'\\''");
162
- const session = JSON.stringify(sessionName);
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/tmux
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 tmux + agent
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
- try {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@armstrongnate/april",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
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": {