@generativereality/agentherder 0.1.5 → 0.1.7
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/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +99 -13
- package/package.json +1 -1
- package/skills/herd/SKILL.md +24 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentherder",
|
|
3
3
|
"description": "Run a fleet of Claude Code sessions. Terminal tabs as the UI, no tmux. Claude can orchestrate its own sibling sessions.",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.7",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "generativereality",
|
|
7
7
|
"url": "https://agentherder.com"
|
package/dist/index.js
CHANGED
|
@@ -4,13 +4,13 @@ import { cli, define } from "gunshi";
|
|
|
4
4
|
import { createConnection } from "net";
|
|
5
5
|
import { execFileSync, spawnSync } from "child_process";
|
|
6
6
|
import { randomUUID } from "crypto";
|
|
7
|
-
import { homedir } from "os";
|
|
7
|
+
import { homedir, tmpdir } from "os";
|
|
8
8
|
import { basename, dirname, extname, join, resolve } from "path";
|
|
9
9
|
import { consola } from "consola";
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
11
11
|
//#region package.json
|
|
12
12
|
var name = "@generativereality/agentherder";
|
|
13
|
-
var version = "0.1.
|
|
13
|
+
var version = "0.1.7";
|
|
14
14
|
var description = "Agent session manager for AI coding tools. Terminal tabs as the UI, no tmux.";
|
|
15
15
|
var package_default = {
|
|
16
16
|
name,
|
|
@@ -502,8 +502,21 @@ function ensureConfigExists() {
|
|
|
502
502
|
}
|
|
503
503
|
//#endregion
|
|
504
504
|
//#region src/core/open-session.ts
|
|
505
|
+
/** Poll scrollback until Claude's ❯ prompt is visible, then return */
|
|
506
|
+
async function waitForClaudePrompt(adapter, blockId, timeoutMs = 3e4) {
|
|
507
|
+
const POLL_INTERVAL = 1e3;
|
|
508
|
+
const deadline = Date.now() + timeoutMs;
|
|
509
|
+
while (Date.now() < deadline) {
|
|
510
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL));
|
|
511
|
+
try {
|
|
512
|
+
const lines = adapter.scrollback(blockId, 10);
|
|
513
|
+
if (lines && lines.includes("❯")) return;
|
|
514
|
+
} catch {}
|
|
515
|
+
}
|
|
516
|
+
consola.warn("Timed out waiting for Claude prompt — sending task anyway");
|
|
517
|
+
}
|
|
505
518
|
async function openSession(opts) {
|
|
506
|
-
const { tabName, claudeCmd, workspaceQuery } = opts;
|
|
519
|
+
const { tabName, claudeCmd, workspaceQuery, initialPromptFile } = opts;
|
|
507
520
|
const dir = resolve(opts.dir.replace(/^~/, homedir()));
|
|
508
521
|
if (!existsSync(dir)) {
|
|
509
522
|
consola.error(`Directory does not exist: ${dir}`);
|
|
@@ -539,6 +552,12 @@ async function openSession(opts) {
|
|
|
539
552
|
const extraFlags = config.claude.flags.join(" ");
|
|
540
553
|
const cmd = `cd ${JSON.stringify(dir)} && ${claudeCmd} --name ${JSON.stringify(tabName)}${extraFlags ? " " + extraFlags : ""}\n`;
|
|
541
554
|
await adapter.sendInput(blockId, cmd);
|
|
555
|
+
if (initialPromptFile) {
|
|
556
|
+
await waitForClaudePrompt(adapter, blockId);
|
|
557
|
+
const prompt = readFileSync(initialPromptFile, "utf-8");
|
|
558
|
+
const text = prompt.endsWith("\n") ? prompt : prompt + "\n";
|
|
559
|
+
await adapter.sendInput(blockId, text);
|
|
560
|
+
}
|
|
542
561
|
await new Promise((r) => setTimeout(r, 2e3));
|
|
543
562
|
adapter.closeSocket();
|
|
544
563
|
return tabId;
|
|
@@ -566,6 +585,16 @@ const newCommand = define({
|
|
|
566
585
|
type: "boolean",
|
|
567
586
|
short: "W",
|
|
568
587
|
description: "Launch claude with --worktree <name> for isolated branch work"
|
|
588
|
+
},
|
|
589
|
+
file: {
|
|
590
|
+
type: "string",
|
|
591
|
+
short: "f",
|
|
592
|
+
description: "Send initial prompt from file once Claude is ready"
|
|
593
|
+
},
|
|
594
|
+
prompt: {
|
|
595
|
+
type: "string",
|
|
596
|
+
short: "p",
|
|
597
|
+
description: "Send initial prompt text once Claude is ready"
|
|
569
598
|
}
|
|
570
599
|
},
|
|
571
600
|
async run(ctx) {
|
|
@@ -573,15 +602,23 @@ const newCommand = define({
|
|
|
573
602
|
const dir = ctx.positionals[2] ?? process.cwd();
|
|
574
603
|
const workspace = ctx.values.workspace;
|
|
575
604
|
const useWorktree = ctx.values.worktree ?? false;
|
|
605
|
+
const promptFile = ctx.values.file;
|
|
606
|
+
const promptText = ctx.values.prompt;
|
|
576
607
|
if (!name) {
|
|
577
608
|
consola.error("Tab name is required");
|
|
578
609
|
process.exit(1);
|
|
579
610
|
}
|
|
611
|
+
let initialPromptFile;
|
|
612
|
+
if (promptText) {
|
|
613
|
+
initialPromptFile = join(tmpdir(), `herd-prompt-${Date.now()}.txt`);
|
|
614
|
+
writeFileSync(initialPromptFile, promptText);
|
|
615
|
+
} else if (promptFile) initialPromptFile = promptFile;
|
|
580
616
|
const tabId = await openSession({
|
|
581
617
|
tabName: name,
|
|
582
618
|
dir,
|
|
583
619
|
claudeCmd: useWorktree ? `claude --worktree ${JSON.stringify(name)}` : "claude",
|
|
584
|
-
workspaceQuery: workspace
|
|
620
|
+
workspaceQuery: workspace,
|
|
621
|
+
initialPromptFile
|
|
585
622
|
});
|
|
586
623
|
const suffix = useWorktree ? ` (worktree: .claude/worktrees/${name})` : "";
|
|
587
624
|
consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude at ${dir}${suffix}`);
|
|
@@ -662,6 +699,31 @@ function findLatestSessionId(dir) {
|
|
|
662
699
|
}
|
|
663
700
|
return null;
|
|
664
701
|
}
|
|
702
|
+
/**
|
|
703
|
+
* Find the most recently created session ID after a given timestamp.
|
|
704
|
+
* Used by `herd fork` to detect the session Claude created in response to /branch.
|
|
705
|
+
*/
|
|
706
|
+
function findNewestSessionIdSince(dir, sinceMs) {
|
|
707
|
+
const projectsRoot = join(homedir(), ".claude", "projects");
|
|
708
|
+
const candidates = [];
|
|
709
|
+
function scanProjectDir(projectDir) {
|
|
710
|
+
if (!existsSync(projectDir)) return;
|
|
711
|
+
for (const f of readdirSync(projectDir)) {
|
|
712
|
+
if (extname(f) !== ".jsonl") continue;
|
|
713
|
+
const mtime = statSync(join(projectDir, f)).mtimeMs;
|
|
714
|
+
if (mtime > sinceMs) candidates.push({
|
|
715
|
+
id: basename(f, ".jsonl"),
|
|
716
|
+
mtime
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
scanProjectDir(join(projectsRoot, pathToProjectSlug(dir)));
|
|
721
|
+
const worktreesDir = join(dir, ".claude", "worktrees");
|
|
722
|
+
if (existsSync(worktreesDir)) for (const entry of readdirSync(worktreesDir)) scanProjectDir(join(projectsRoot, pathToProjectSlug(join(worktreesDir, entry))));
|
|
723
|
+
if (!candidates.length) return null;
|
|
724
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
725
|
+
return candidates[0].id;
|
|
726
|
+
}
|
|
665
727
|
//#endregion
|
|
666
728
|
//#region src/commands/fork.ts
|
|
667
729
|
/** If dir is inside .claude/worktrees/<name>, return the repo root instead */
|
|
@@ -682,7 +744,7 @@ function resolveSessionDir(dir) {
|
|
|
682
744
|
}
|
|
683
745
|
const forkCommand = define({
|
|
684
746
|
name: "fork",
|
|
685
|
-
description: "Fork a session into a new tab
|
|
747
|
+
description: "Fork a session into a new tab by sending /branch to the source tab",
|
|
686
748
|
args: {
|
|
687
749
|
tab: {
|
|
688
750
|
type: "positional",
|
|
@@ -705,7 +767,7 @@ const forkCommand = define({
|
|
|
705
767
|
const { tabsById, tabNames } = await adapter.getAllData();
|
|
706
768
|
const matches = adapter.resolveTab(sourceQuery, tabsById, tabNames);
|
|
707
769
|
if (!matches.length) {
|
|
708
|
-
consola.error(`No tab matching '${sourceQuery}'`);
|
|
770
|
+
consola.error(`No tab matching '${sourceQuery}' (tabs in workspaces with no open window are not visible — open that workspace first)`);
|
|
709
771
|
process.exit(1);
|
|
710
772
|
}
|
|
711
773
|
if (matches.length > 1) {
|
|
@@ -722,19 +784,43 @@ const forkCommand = define({
|
|
|
722
784
|
process.exit(1);
|
|
723
785
|
}
|
|
724
786
|
const { sessionLookupDir, openDir } = resolveSessionDir(termBlocks[0].meta?.["cmd:cwd"] ?? process.cwd());
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
787
|
+
const sourceBlockId = termBlocks[0].blockid;
|
|
788
|
+
consola.info(`Sending /branch to "${tabName}"…`);
|
|
789
|
+
const before = Date.now();
|
|
790
|
+
await adapter.sendInput(sourceBlockId, "/branch\n");
|
|
791
|
+
adapter.closeSocket();
|
|
792
|
+
const POLL_INTERVAL = 500;
|
|
793
|
+
const TIMEOUT = 1e4;
|
|
794
|
+
let newSessionId = null;
|
|
795
|
+
const deadline = Date.now() + TIMEOUT;
|
|
796
|
+
while (Date.now() < deadline) {
|
|
797
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL));
|
|
798
|
+
newSessionId = findNewestSessionIdSince(sessionLookupDir, before);
|
|
799
|
+
if (newSessionId) break;
|
|
800
|
+
}
|
|
801
|
+
if (!newSessionId) {
|
|
802
|
+
consola.warn("No new session detected after /branch — falling back to --fork-session");
|
|
803
|
+
const fallbackId = findLatestSessionId(sessionLookupDir);
|
|
804
|
+
if (!fallbackId) {
|
|
805
|
+
consola.error(`No Claude session found for ${sessionLookupDir}`);
|
|
806
|
+
consola.info(`Looked in ~/.claude/projects/${pathToProjectSlug(sessionLookupDir)}/`);
|
|
807
|
+
process.exit(1);
|
|
808
|
+
}
|
|
809
|
+
const newTabId = await openSession({
|
|
810
|
+
tabName: newName,
|
|
811
|
+
dir: openDir,
|
|
812
|
+
claudeCmd: `claude --resume ${fallbackId} --fork-session`
|
|
813
|
+
});
|
|
814
|
+
consola.success(`Forked "${tabName}" → "${newName}" [${newTabId.slice(0, 8)}] (via --fork-session)`);
|
|
815
|
+
return;
|
|
730
816
|
}
|
|
731
817
|
const newTabId = await openSession({
|
|
732
818
|
tabName: newName,
|
|
733
819
|
dir: openDir,
|
|
734
|
-
claudeCmd: `claude --resume ${
|
|
820
|
+
claudeCmd: `claude --resume ${newSessionId}`
|
|
735
821
|
});
|
|
736
822
|
consola.success(`Forked "${tabName}" → "${newName}" [${newTabId.slice(0, 8)}]`);
|
|
737
|
-
consola.info(`session: ${
|
|
823
|
+
consola.info(`session: ${newSessionId}`);
|
|
738
824
|
}
|
|
739
825
|
});
|
|
740
826
|
//#endregion
|
package/package.json
CHANGED
package/skills/herd/SKILL.md
CHANGED
|
@@ -30,7 +30,7 @@ Each session runs in its own terminal tab. `herd` lets you — and other Claude
|
|
|
30
30
|
```bash
|
|
31
31
|
herd sessions # list all tabs with session status
|
|
32
32
|
herd list # list all workspaces, tabs, and blocks
|
|
33
|
-
herd new <name> [dir] [-w workspace]
|
|
33
|
+
herd new <name> [dir] [-w workspace] [-p "prompt"] [-f file] # new tab + claude
|
|
34
34
|
herd resume <name> [dir] # new tab + claude --continue
|
|
35
35
|
herd fork <tab-name> [-n new-name] # fork a session into a new tab
|
|
36
36
|
herd close <name-or-id> # close a tab
|
|
@@ -84,26 +84,44 @@ herd resume api ~/Dev/myapp
|
|
|
84
84
|
|
|
85
85
|
## Workflow: Forking a Session
|
|
86
86
|
|
|
87
|
-
Use `fork` when you want to
|
|
87
|
+
Use `fork` when you want to explore an alternative approach without disrupting the original.
|
|
88
|
+
`herd fork` sends `/branch` to the source tab (Claude's built-in conversation fork command),
|
|
89
|
+
waits for the new session to be written, then opens it in a new tab.
|
|
88
90
|
|
|
89
91
|
```bash
|
|
90
92
|
herd fork auth # creates "auth-fork" tab
|
|
91
93
|
herd fork auth -n "auth-v2" # creates "auth-v2" tab
|
|
92
94
|
```
|
|
93
95
|
|
|
94
|
-
The forked session
|
|
96
|
+
The forked session shares full conversation history up to the branch point, then diverges independently.
|
|
97
|
+
If Claude does not respond to `/branch` in time, herd falls back to `claude --resume <id> --fork-session`.
|
|
98
|
+
|
|
99
|
+
**In-session equivalent**: typing `/branch` (alias `/fork`) directly in Claude produces the same fork —
|
|
100
|
+
use `herd resume <name> <dir>` afterwards to open the resulting session in a new tab.
|
|
95
101
|
|
|
96
102
|
## Workflow: Spawning a Parallel Agent
|
|
97
103
|
|
|
98
104
|
As a Claude Code session, you can spawn a sibling session to work on a parallel task:
|
|
99
105
|
|
|
106
|
+
**Preferred: pass the initial task directly to `herd new`** using `--prompt` or `--file`. This polls internally until Claude's `❯` prompt appears before sending — no race condition:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
herd new payments ~/Dev/myapp --prompt "implement the billing endpoint"
|
|
110
|
+
herd new payments ~/Dev/myapp --file /tmp/task.txt
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
If you need to send a task after the fact, poll first:
|
|
114
|
+
|
|
100
115
|
```bash
|
|
101
116
|
herd new payments ~/Dev/myapp
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
herd send payments
|
|
117
|
+
# Poll until ❯ appears (typically 10-15s with MCP servers)
|
|
118
|
+
herd scrollback payments 5 # repeat until you see ❯
|
|
119
|
+
herd send payments --file /tmp/task.txt
|
|
120
|
+
herd send payments "yes\n" # quick replies
|
|
105
121
|
```
|
|
106
122
|
|
|
123
|
+
**Do NOT call `herd send` immediately after `herd new`** — Claude is still starting up and the text will land as raw shell commands.
|
|
124
|
+
|
|
107
125
|
## Workflow: Monitoring Another Session
|
|
108
126
|
|
|
109
127
|
```bash
|