@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.
@@ -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.5",
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.5";
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 (claude --resume <id> --fork-session)",
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 sessionId = findLatestSessionId(sessionLookupDir);
726
- if (!sessionId) {
727
- consola.error(`No Claude session found for ${sessionLookupDir}`);
728
- consola.info(`Looked in ~/.claude/projects/${pathToProjectSlug(sessionLookupDir)}/`);
729
- process.exit(1);
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 ${sessionId} --fork-session`
820
+ claudeCmd: `claude --resume ${newSessionId}`
735
821
  });
736
822
  consola.success(`Forked "${tabName}" → "${newName}" [${newTabId.slice(0, 8)}]`);
737
- consola.info(`session: ${sessionId}`);
823
+ consola.info(`session: ${newSessionId}`);
738
824
  }
739
825
  });
740
826
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@generativereality/agentherder",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Agent session manager for AI coding tools. Terminal tabs as the UI, no tmux.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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] # new tab + claude
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 try an alternative approach without disrupting the original:
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 runs `claude --resume <session-id> --fork-session` it shares context from the original but creates an independent new 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
- herd send payments --file /tmp/task.txt # send a prompt from a file
103
- echo "implement the billing endpoint" | herd send payments # or via stdin
104
- herd send payments "yes\n" # or inline for quick replies
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