@armstrongnate/april 0.1.3 → 0.2.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.
@@ -0,0 +1,93 @@
1
+ import { loadConfig } from "../config.js";
2
+ import { getAgent } from "../agents.js";
3
+ import { getSessionBackend } from "../session/index.js";
4
+ import { slugify } from "../slug.js";
5
+ import { resolveRepo, INVESTIGATE_PREFIX } from "../work.js";
6
+ import { INVESTIGATE_SKILL } from "../skill.js";
7
+ const USAGE = `april investigate "<problem>" [--repo OWNER/NAME] [--auto]
8
+
9
+ Dispatch a research agent in the CURRENT directory to investigate a problem
10
+ and file a GitHub issue. The session may span repos; it is not tied to a
11
+ worktree.
12
+
13
+ "<problem>" free-text description of what to investigate
14
+ --repo suggest the owning repo (the agent still decides where it belongs)
15
+ --auto create the issue already labeled so april picks it up immediately
16
+ --review explicit deferred mode (the default): file for human review, no label
17
+ --dry-run print the session name and the prompt that would be dispatched, then exit`;
18
+ export async function run(args) {
19
+ let repoFlag;
20
+ let auto = false;
21
+ let dryRun = false;
22
+ const words = [];
23
+ for (let i = 0; i < args.length; i++) {
24
+ const a = args[i];
25
+ if (a === "--repo") {
26
+ repoFlag = args[++i];
27
+ if (!repoFlag)
28
+ throw new Error("--repo requires a value");
29
+ }
30
+ else if (a === "--auto") {
31
+ auto = true;
32
+ }
33
+ else if (a === "--review") {
34
+ auto = false;
35
+ }
36
+ else if (a === "--dry-run") {
37
+ dryRun = true;
38
+ }
39
+ else if (a.startsWith("--")) {
40
+ console.error(`Unknown option: ${a}\n\n${USAGE}`);
41
+ return 2;
42
+ }
43
+ else {
44
+ words.push(a);
45
+ }
46
+ }
47
+ const problem = words.join(" ").trim();
48
+ if (!problem) {
49
+ console.error(`Missing problem description.\n\n${USAGE}`);
50
+ return 2;
51
+ }
52
+ const config = loadConfig();
53
+ // Validate the hint early so a typo'd --repo fails before spawning a session.
54
+ const suggested = repoFlag ? resolveRepo(config, repoFlag) : undefined;
55
+ const mode = auto ? "auto" : "deferred";
56
+ const cwd = process.cwd();
57
+ const shortId = Date.now().toString(36).slice(-4);
58
+ const slug = `${INVESTIGATE_PREFIX}${shortId}-${slugify(problem, 30, "investigation")}`;
59
+ const repoList = config.repos
60
+ .map((r) => ` - ${r.owner}/${r.name} — ${r.path}`)
61
+ .join("\n");
62
+ const modeLine = mode === "auto"
63
+ ? `create the issue assigned to ${config.assignee} AND apply the "${config.label}" label so april picks it up immediately.`
64
+ : `create the issue assigned to ${config.assignee} WITHOUT the "${config.label}" label, then print the URL for review.`;
65
+ const body = [
66
+ problem,
67
+ "",
68
+ "Context for filing the issue:",
69
+ `- Assignee: ${config.assignee}`,
70
+ `- Trigger label: ${config.label}`,
71
+ `- Mode: ${mode}`,
72
+ ...(suggested ? [`- Suggested owning repo: ${suggested.owner}/${suggested.name}`] : []),
73
+ "- Candidate repos:",
74
+ repoList,
75
+ "",
76
+ `Investigate the problem above, then ${modeLine}`,
77
+ ].join("\n");
78
+ const agent = getAgent(config.llm);
79
+ const prompt = agent.buildPrompt(INVESTIGATE_SKILL, body);
80
+ const command = agent.buildCommand(config);
81
+ if (dryRun) {
82
+ console.log(`session: ${slug}`);
83
+ console.log(`cwd: ${cwd}`);
84
+ console.log(`mode: ${mode}`);
85
+ console.log(`command: ${command}`);
86
+ console.log(`\nprompt:\n${prompt}`);
87
+ return 0;
88
+ }
89
+ console.log(`Starting investigation "${slug}" in ${cwd} (${mode} mode)…`);
90
+ await getSessionBackend(config).spawn({ name: slug, cwd, command, prompt });
91
+ console.log(`Done. Track it with \`april ps\`; attach to the session "${slug}".`);
92
+ return 0;
93
+ }
@@ -0,0 +1,91 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { findConfigPath, parseConfigFile } from "../config.js";
3
+ import { getSessionBackend } from "../session/index.js";
4
+ import { listActiveWork } from "../work.js";
5
+ const USAGE = `april kill <slug|issue> [--repo OWNER/NAME] [--worktree]
6
+
7
+ Kill a single session by slug or issue number. Works for investigations
8
+ (inv-…) too. With --worktree, also remove the backing worktree.
9
+
10
+ <slug|issue> a session slug (gh-123-…, inv-…) or an issue number (123)
11
+ --repo disambiguate when an issue number matches more than one repo
12
+ --worktree also remove the worktree (only applies to issue work)`;
13
+ export async function run(args) {
14
+ let target;
15
+ let repoFlag;
16
+ let withWorktree = false;
17
+ for (let i = 0; i < args.length; i++) {
18
+ const a = args[i];
19
+ if (a === "--repo") {
20
+ repoFlag = args[++i];
21
+ if (!repoFlag)
22
+ throw new Error("--repo requires a value");
23
+ }
24
+ else if (a === "--worktree") {
25
+ withWorktree = true;
26
+ }
27
+ else if (a.startsWith("--")) {
28
+ console.error(`Unknown option: ${a}\n\n${USAGE}`);
29
+ return 2;
30
+ }
31
+ else if (!target) {
32
+ target = a;
33
+ }
34
+ else {
35
+ console.error(`Unexpected argument: ${a}\n\n${USAGE}`);
36
+ return 2;
37
+ }
38
+ }
39
+ if (!target) {
40
+ console.error(`Missing slug or issue number.\n\n${USAGE}`);
41
+ return 2;
42
+ }
43
+ const config = parseConfigFile(findConfigPath());
44
+ const items = await listActiveWork(config);
45
+ let slug;
46
+ let item;
47
+ if (/^#?\d+$/.test(target)) {
48
+ const n = parseInt(target.replace(/^#/, ""), 10);
49
+ let matches = items.filter((it) => it.issueNumber === n);
50
+ if (repoFlag)
51
+ matches = matches.filter((it) => it.repo === repoFlag || it.repo?.endsWith(`/${repoFlag}`));
52
+ if (matches.length === 0) {
53
+ console.log(`No active session or worktree for issue #${n}.`);
54
+ return 0;
55
+ }
56
+ if (matches.length > 1) {
57
+ console.error(`#${n} matches multiple repos — pass --repo OWNER/NAME:`);
58
+ for (const m of matches)
59
+ console.error(` ${m.repo} (${m.slug})`);
60
+ return 2;
61
+ }
62
+ item = matches[0];
63
+ slug = item.slug;
64
+ }
65
+ else {
66
+ slug = target;
67
+ item = items.find((it) => it.slug === target);
68
+ }
69
+ console.log(`Killing session "${slug}"…`);
70
+ await getSessionBackend(config).kill(slug);
71
+ if (withWorktree) {
72
+ if (item?.repoPath && item.worktreePath) {
73
+ try {
74
+ execFileSync("wt", ["remove", slug, "-f", "-D"], {
75
+ cwd: item.repoPath,
76
+ timeout: 60_000,
77
+ stdio: "pipe",
78
+ });
79
+ console.log(`Removed worktree ${item.worktreePath}`);
80
+ }
81
+ catch (err) {
82
+ console.warn(`wt remove ${slug} failed: ${err instanceof Error ? err.message : String(err)}`);
83
+ }
84
+ }
85
+ else {
86
+ console.log("No worktree associated with this session — nothing to remove.");
87
+ }
88
+ }
89
+ console.log("Done.");
90
+ return 0;
91
+ }
@@ -0,0 +1,70 @@
1
+ import { findConfigPath, parseConfigFile } from "../config.js";
2
+ import { listActiveWork } from "../work.js";
3
+ import { daemonStatus } from "../daemon.js";
4
+ const USAGE = `april ps [--json]
5
+
6
+ List active work — issues in flight and investigations — derived from
7
+ worktrees and live sessions. Works whether or not the daemon is running.`;
8
+ function fmtUptime(seconds) {
9
+ const s = Math.floor(seconds);
10
+ const h = Math.floor(s / 3600);
11
+ const m = Math.floor((s % 3600) / 60);
12
+ if (h > 0)
13
+ return `${h}h${m}m`;
14
+ if (m > 0)
15
+ return `${m}m${s % 60}s`;
16
+ return `${s}s`;
17
+ }
18
+ function renderTable(items) {
19
+ const header = ["KIND", "ISSUE", "SESSION", "REPO", "SLUG"];
20
+ const rows = items.map((it) => [
21
+ it.kind === "investigation" ? "investigate" : "issue",
22
+ it.issueNumber ? `#${it.issueNumber}` : "-",
23
+ it.sessionAlive ? "live" : "stale",
24
+ it.repo ?? "-",
25
+ it.slug,
26
+ ]);
27
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
28
+ const fmt = (cols) => cols.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
29
+ console.log(fmt(header));
30
+ for (const r of rows)
31
+ console.log(fmt(r));
32
+ }
33
+ export async function run(args) {
34
+ const wantJson = args.includes("--json");
35
+ for (const a of args) {
36
+ if (a !== "--json") {
37
+ console.error(`Unknown option: ${a}\n\n${USAGE}`);
38
+ return 2;
39
+ }
40
+ }
41
+ let config;
42
+ try {
43
+ config = parseConfigFile(findConfigPath());
44
+ }
45
+ catch (err) {
46
+ console.error(err instanceof Error ? err.message : String(err));
47
+ return 1;
48
+ }
49
+ const items = await listActiveWork(config);
50
+ const status = await daemonStatus(config.port);
51
+ if (wantJson) {
52
+ console.log(JSON.stringify({ daemon: status, work: items }, null, 2));
53
+ return 0;
54
+ }
55
+ if (status) {
56
+ const fw = status.forwarders;
57
+ const liveFw = fw.filter((f) => f.alive).length;
58
+ console.log(`daemon: up (${fmtUptime(status.uptime)}), ${liveFw}/${fw.length} forwarder${fw.length === 1 ? "" : "s"} live`);
59
+ }
60
+ else {
61
+ console.log(`daemon: not reachable on :${config.port}`);
62
+ }
63
+ console.log("");
64
+ if (items.length === 0) {
65
+ console.log("No active work.");
66
+ return 0;
67
+ }
68
+ renderTable(items);
69
+ return 0;
70
+ }
@@ -0,0 +1,46 @@
1
+ import { loadConfig } from "../config.js";
2
+ import { handleNewIssue } from "../spawner.js";
3
+ import { parseIssueRef, resolveRepo, fetchIssue } from "../work.js";
4
+ const USAGE = `april run <issue> [--repo OWNER/NAME]
5
+
6
+ Manually start work on an issue — the same path the daemon takes on a
7
+ labeled+assigned webhook, minus the label requirement. Creates the worktree,
8
+ spawns the agent session, and flips labels to agent:wip.
9
+
10
+ <issue> 123, #123, or owner/name#123
11
+ --repo Repo to act on when <issue> is a bare number and multiple are configured.`;
12
+ export async function run(args) {
13
+ let ref;
14
+ let repoFlag;
15
+ for (let i = 0; i < args.length; i++) {
16
+ const a = args[i];
17
+ if (a === "--repo") {
18
+ repoFlag = args[++i];
19
+ if (!repoFlag)
20
+ throw new Error("--repo requires a value");
21
+ }
22
+ else if (a.startsWith("--")) {
23
+ console.error(`Unknown option: ${a}\n\n${USAGE}`);
24
+ return 2;
25
+ }
26
+ else if (!ref) {
27
+ ref = a;
28
+ }
29
+ else {
30
+ console.error(`Unexpected argument: ${a}\n\n${USAGE}`);
31
+ return 2;
32
+ }
33
+ }
34
+ if (!ref) {
35
+ console.error(`Missing issue reference.\n\n${USAGE}`);
36
+ return 2;
37
+ }
38
+ const config = loadConfig();
39
+ const { repoRef, number } = parseIssueRef(ref);
40
+ const repo = resolveRepo(config, repoFlag ?? repoRef);
41
+ const issue = fetchIssue(repo, number);
42
+ console.log(`Starting work on ${repo.owner}/${repo.name}#${issue.number} — "${issue.title}"`);
43
+ await handleNewIssue(repo, issue, config);
44
+ console.log(`Done. Track it with \`april ps\`.`);
45
+ return 0;
46
+ }
package/dist/config.js CHANGED
@@ -7,7 +7,7 @@ import { createLogger } from "./logger.js";
7
7
  import { isGhWebhookExtensionInstalled, GH_EXTENSION_INSTALL_CMD } from "./precheck.js";
8
8
  import { AGENT_KINDS, getAgent } from "./agents.js";
9
9
  const log = createLogger("config");
10
- function findConfigPath() {
10
+ export function findConfigPath() {
11
11
  const envPath = process.env.APRIL_CONFIG;
12
12
  if (envPath) {
13
13
  if (!existsSync(envPath)) {
@@ -29,13 +29,23 @@ function findConfigPath() {
29
29
  ` - ${localPath}\n` +
30
30
  "Create a config.yaml (see config.example.yaml).");
31
31
  }
32
+ /** Whether a binary is resolvable on PATH. Non-throwing; used by `doctor`. */
33
+ export function checkTool(tool) {
34
+ try {
35
+ execSync(`which ${tool}`, { stdio: "pipe" });
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ /** The external binaries april needs for this agent + session backend. */
43
+ export function requiredTools(agentCli, sessionManager) {
44
+ return ["gh", "git", agentCli, sessionManager === "herdr" ? "herdr" : "tmux"];
45
+ }
32
46
  function validateTools(agentCli, sessionManager) {
33
- const tools = ["gh", "git", agentCli, sessionManager === "herdr" ? "herdr" : "tmux"];
34
- for (const tool of tools) {
35
- try {
36
- execSync(`which ${tool}`, { stdio: "pipe" });
37
- }
38
- catch {
47
+ for (const tool of requiredTools(agentCli, sessionManager)) {
48
+ if (!checkTool(tool)) {
39
49
  throw new Error(`Required tool "${tool}" not found on PATH. Install it before running april.`);
40
50
  }
41
51
  }
package/dist/daemon.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Tiny client for the running daemon's HTTP endpoints. Used by `ps`/`doctor`
3
+ * to enrich their output when the daemon is up; every caller treats `null`
4
+ * (daemon down/unreachable) as a normal, non-error condition.
5
+ */
6
+ async function getJson(url, timeoutMs = 1500) {
7
+ const controller = new AbortController();
8
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
9
+ try {
10
+ const res = await fetch(url, { signal: controller.signal });
11
+ if (!res.ok)
12
+ return null;
13
+ return (await res.json());
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ finally {
19
+ clearTimeout(timer);
20
+ }
21
+ }
22
+ export function daemonStatus(port) {
23
+ return getJson(`http://127.0.0.1:${port}/status`);
24
+ }
25
+ export async function daemonReachable(port) {
26
+ return (await getJson(`http://127.0.0.1:${port}/health`)) !== null;
27
+ }
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
2
- import { join, dirname } from "node:path";
2
+ import { join, dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { homedir } from "node:os";
4
5
  import { createLogger } from "./logger.js";
5
6
  import { loadConfig } from "./config.js";
@@ -8,6 +9,16 @@ import { startServer } from "./server.js";
8
9
  import { startWebhookForwarders, shutdownForwarders } from "./processes.js";
9
10
  const log = createLogger("main");
10
11
  const PID_PATH = join(homedir(), ".config", "april", "april.pid");
12
+ function readVersion() {
13
+ try {
14
+ // dist/index.js -> package root
15
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
16
+ return JSON.parse(readFileSync(join(root, "package.json"), "utf-8")).version ?? "unknown";
17
+ }
18
+ catch {
19
+ return "unknown";
20
+ }
21
+ }
11
22
  function checkPidFile() {
12
23
  if (!existsSync(PID_PATH))
13
24
  return;
@@ -42,7 +53,7 @@ function removePidFile() {
42
53
  }
43
54
  async function main() {
44
55
  console.log("");
45
- console.log(" april v0.0.1");
56
+ console.log(` april v${readVersion()}`);
46
57
  console.log("");
47
58
  // 1. Load config
48
59
  const config = loadConfig();
package/dist/processes.js CHANGED
@@ -95,6 +95,14 @@ function spawnForwarder(config, repoKey, url) {
95
95
  start();
96
96
  return state;
97
97
  }
98
+ /** Snapshot of the webhook forwarders, for the daemon's /status endpoint. */
99
+ export function getForwarderStatus() {
100
+ return forwarders.map((f) => ({
101
+ repoKey: f.repoKey,
102
+ alive: !!f.child && !f.child.killed && f.child.exitCode === null && !f.stopped,
103
+ consecutiveFailures: f.consecutiveFailures,
104
+ }));
105
+ }
98
106
  export function startWebhookForwarders(config) {
99
107
  const url = `http://localhost:${config.port}/webhook/github`;
100
108
  for (const repo of config.repos) {
package/dist/server.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import Fastify from "fastify";
2
2
  import { createLogger } from "./logger.js";
3
3
  import { parseWebhookEvent } from "./webhook.js";
4
- import { isIssueActive, handlePrClosed } from "./spawner.js";
4
+ import { isIssueActive, handlePrClosed, getActiveCounts } from "./spawner.js";
5
+ import { getForwarderStatus } from "./processes.js";
5
6
  const log = createLogger("server");
6
7
  export async function startServer(config, onNewIssue) {
7
8
  const app = Fastify({ logger: false });
@@ -68,6 +69,17 @@ export async function startServer(config, onNewIssue) {
68
69
  return reply.code(200).send({ ok: true });
69
70
  });
70
71
  app.get("/health", async () => ({ status: "ok" }));
72
+ // Live daemon state for `april ps` / `april doctor`. Everything here is also
73
+ // derivable from disk, so the CLI degrades gracefully when the daemon is down.
74
+ app.get("/status", async () => ({
75
+ uptime: process.uptime(),
76
+ assignee: config.assignee,
77
+ label: config.label,
78
+ sessionManager: config.sessionManager ?? "tmux",
79
+ repos: config.repos.map((r) => `${r.owner}/${r.name}`),
80
+ active: await getActiveCounts(config),
81
+ forwarders: getForwarderStatus(),
82
+ }));
71
83
  await app.listen({ port: config.port, host: "127.0.0.1" });
72
84
  log.info(`Server listening on http://127.0.0.1:${config.port}`);
73
85
  return app;
@@ -50,7 +50,7 @@ function ensureSystemctlPresent() {
50
50
  throw new Error("systemctl not found on PATH. april service install requires systemd.");
51
51
  }
52
52
  }
53
- function lingerEnabled() {
53
+ export function lingerEnabled() {
54
54
  try {
55
55
  const out = execFileSync("loginctl", ["show-user", process.env.USER ?? ""], {
56
56
  encoding: "utf-8",
@@ -35,11 +35,45 @@ async function waitForAgentReady(paneId, attempts = 10, intervalMs = 1000) {
35
35
  log.warn(`Agent in pane ${paneId} never reported a known status; proceeding anyway`);
36
36
  }
37
37
  const SUBMIT_SETTLE_MS = 1000;
38
+ const INJECT_ATTEMPTS = 4;
38
39
  /** Read a pane's current agent status (undefined if unavailable). */
39
40
  async function paneStatus(paneId) {
40
41
  const res = await herdrRequest("pane.get", { pane_id: paneId });
41
42
  return res.pane?.agent_status;
42
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
+ }
43
77
  /**
44
78
  * Wait for the agent to leave `idle` — confirmation that the Enter submitted
45
79
  * and the agent picked up the prompt. `working`/`blocked`/`done` all mean the
@@ -48,8 +82,7 @@ async function paneStatus(paneId) {
48
82
  async function confirmSubmitted(paneId, attempts = 6, intervalMs = 500) {
49
83
  for (let i = 0; i < attempts; i++) {
50
84
  try {
51
- const status = await paneStatus(paneId);
52
- if (status && status !== "idle" && status !== "unknown")
85
+ if (isAccepted(await paneStatus(paneId)))
53
86
  return true;
54
87
  }
55
88
  catch {
@@ -59,21 +92,53 @@ async function confirmSubmitted(paneId, attempts = 6, intervalMs = 500) {
59
92
  }
60
93
  return false;
61
94
  }
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"] });
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);
77
142
  }
78
143
  export const herdrBackend = {
79
144
  async listSessions() {
@@ -128,10 +193,15 @@ export const herdrBackend = {
128
193
  }
129
194
  }
130
195
  log.info(`Started agent in herdr workspace "${name}" (pane ${paneId})`);
131
- // Wait until the agent is up, then inject the prompt.
196
+ // Wait until the agent is up, then inject the prompt and verify it landed.
132
197
  await waitForAgentReady(paneId);
133
- await submit(paneId, prompt);
134
- log.info(`Prompt sent to herdr workspace "${name}"`);
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
+ }
135
205
  },
136
206
  async kill(name) {
137
207
  const ws = await findByLabel(name);
package/dist/skill.js CHANGED
@@ -3,20 +3,25 @@ import { dirname, resolve } from "node:path";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { configuredLlmKind } from "./config.js";
5
5
  import { getAgent } from "./agents.js";
6
- const SKILL_NAME = "issue-worker";
7
- export function bundledSkillPath() {
8
- // dist/skill.js -> dist/.. -> package root, then skills/issue-worker/SKILL.md
6
+ /** Skill that works a labeled issue end-to-end (the daemon's worker). */
7
+ export const ISSUE_WORKER_SKILL = "issue-worker";
8
+ /** Skill that researches a free-text problem and files a GitHub issue. */
9
+ export const INVESTIGATE_SKILL = "issue-investigator";
10
+ /** Every skill april bundles and keeps installed for the configured agent. */
11
+ export const ALL_BUNDLED_SKILLS = [ISSUE_WORKER_SKILL, INVESTIGATE_SKILL];
12
+ export function bundledSkillPath(skill) {
13
+ // dist/skill.js -> dist/.. -> package root, then skills/<skill>/SKILL.md
9
14
  const here = fileURLToPath(import.meta.url);
10
- return resolve(dirname(here), "..", "skills", SKILL_NAME, "SKILL.md");
15
+ return resolve(dirname(here), "..", "skills", skill, "SKILL.md");
11
16
  }
12
- export function skillDestPath(kind = configuredLlmKind()) {
13
- return getAgent(kind).skillFile(SKILL_NAME);
17
+ export function skillDestPath(skill, kind = configuredLlmKind()) {
18
+ return getAgent(kind).skillFile(skill);
14
19
  }
15
- export function compareSkill(kind = configuredLlmKind()) {
16
- const dst = skillDestPath(kind);
20
+ export function compareSkill(skill, kind = configuredLlmKind()) {
21
+ const dst = skillDestPath(skill, kind);
17
22
  if (!existsSync(dst))
18
23
  return "missing";
19
- const src = bundledSkillPath();
24
+ const src = bundledSkillPath(skill);
20
25
  if (!existsSync(src))
21
26
  return "matches-bundled"; // can't compare; don't alarm
22
27
  return readFileSync(dst).equals(readFileSync(src))