@generativereality/herd 0.1.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.
- package/.claude/skills/herd/SKILL.md +147 -0
- package/README.md +180 -0
- package/dist/index.js +927 -0
- package/package.json +57 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: herd
|
|
3
|
+
description: Manage Claude Code sessions across terminal tabs — list running sessions, open new ones, fork, close, inspect output, and send input. Use this when working with multiple parallel Claude Code sessions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are managing Claude Code sessions using the `herd` CLI (Agent Herder).
|
|
7
|
+
|
|
8
|
+
## First: Ensure herd is installed
|
|
9
|
+
|
|
10
|
+
Before running any `herd` command, verify the CLI is available:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
which herd
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
If that returns nothing, install it silently before proceeding:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g @generativereality/herd
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Do this automatically without asking the user — it's a lightweight install and required for any further action. After installing, confirm with `herd --version`.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
Each session runs in its own terminal tab. `herd` lets you — and other Claude Code sessions — introspect and orchestrate the full session fleet.
|
|
27
|
+
|
|
28
|
+
## Quick Reference
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
herd sessions # list all tabs with session status
|
|
32
|
+
herd list # list all workspaces, tabs, and blocks
|
|
33
|
+
herd new <name> [dir] [-w workspace] # new tab + claude
|
|
34
|
+
herd resume <name> [dir] # new tab + claude --continue
|
|
35
|
+
herd fork <tab-name> [-n new-name] # fork a session into a new tab
|
|
36
|
+
herd close <name-or-id> # close a tab
|
|
37
|
+
herd rename <name-or-id> <new-name> # rename a tab
|
|
38
|
+
herd scrollback <tab-or-block> [n] # read terminal output (default: 50 lines)
|
|
39
|
+
herd send <tab-or-block> [text] # send input — arg, --file, or stdin pipe
|
|
40
|
+
herd config # show config and path
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Workflow: Checking What's Running
|
|
44
|
+
|
|
45
|
+
Before starting new sessions, always check what's already active:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
herd sessions
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Output example:
|
|
52
|
+
```
|
|
53
|
+
Sessions
|
|
54
|
+
==================================================
|
|
55
|
+
|
|
56
|
+
Workspace: work (current)
|
|
57
|
+
|
|
58
|
+
[a1b2c3d4] "auth" ◄ ~/Dev/myapp
|
|
59
|
+
● active
|
|
60
|
+
[e5f6a7b8] "api" ~/Dev/myapp
|
|
61
|
+
○ idle
|
|
62
|
+
[c9d0e1f2] "infra" ~/Dev/myapp
|
|
63
|
+
terminal
|
|
64
|
+
last: $ git status
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Workflow: Opening a Session Batch
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
herd new auth ~/Dev/myapp
|
|
71
|
+
herd new api ~/Dev/myapp
|
|
72
|
+
herd new infra ~/Dev/myapp
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Each tab is automatically named and the claude session name is synced to the tab title.
|
|
76
|
+
|
|
77
|
+
## Workflow: Resuming After Restart
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
herd sessions # identify which tabs need resuming
|
|
81
|
+
herd resume auth ~/Dev/myapp
|
|
82
|
+
herd resume api ~/Dev/myapp
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Workflow: Forking a Session
|
|
86
|
+
|
|
87
|
+
Use `fork` when you want to try an alternative approach without disrupting the original:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
herd fork auth # creates "auth-fork" tab
|
|
91
|
+
herd fork auth -n "auth-v2" # creates "auth-v2" tab
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The forked session runs `claude --resume <session-id> --fork-session` — it shares context from the original but creates an independent new session.
|
|
95
|
+
|
|
96
|
+
## Workflow: Spawning a Parallel Agent
|
|
97
|
+
|
|
98
|
+
As a Claude Code session, you can spawn a sibling session to work on a parallel task:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
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
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Workflow: Monitoring Another Session
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
herd scrollback auth # last 50 lines
|
|
111
|
+
herd scrollback auth 200 # last 200 lines
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Workflow: Sending Input to a Session
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
herd send auth "yes\n" # approve a tool call
|
|
118
|
+
herd send auth "\n" # press enter (confirm a prompt)
|
|
119
|
+
herd send auth "/clear\n" # send a slash command
|
|
120
|
+
herd send auth --file ~/prompts/task.txt # send a full prompt from file
|
|
121
|
+
echo "do the thing" | herd send auth # pipe via stdin
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Workflow: Cleanup
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
herd sessions # find idle/terminal tabs
|
|
128
|
+
herd close old-feature # close by name (prefix match)
|
|
129
|
+
herd close e5f6a7b8 # close by block ID prefix
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Tab Naming Conventions
|
|
133
|
+
|
|
134
|
+
Name tabs after the **project or task**:
|
|
135
|
+
- `auth` — authentication work
|
|
136
|
+
- `api` — API service
|
|
137
|
+
- `infra` — infrastructure
|
|
138
|
+
- `pr-1234` — specific PR work
|
|
139
|
+
- `auth-v2` — forked attempt
|
|
140
|
+
|
|
141
|
+
## Notes
|
|
142
|
+
|
|
143
|
+
- Tab names are matched by exact name or prefix (case-insensitive)
|
|
144
|
+
- Block IDs can be abbreviated to the first 8 characters
|
|
145
|
+
- `herd new` and `herd resume` automatically pass `--name <tab-name>` to claude, syncing the session display name with the tab title
|
|
146
|
+
- Configured `claude.flags` in `~/.config/herd/config.toml` are applied to every session
|
|
147
|
+
- `herd send` resolves tab names to their terminal block automatically
|
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Agent Herder
|
|
2
|
+
|
|
3
|
+
**Run a fleet of Claude Code sessions. From the CLI — or from Claude itself.**
|
|
4
|
+
|
|
5
|
+
CLI command: `herd` · Website: [agentherder.com](https://agentherder.com)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
herd new auth ~/Dev/myapp # new tab, claude starts
|
|
9
|
+
herd new api ~/Dev/myapp
|
|
10
|
+
herd new infra ~/Dev/myapp
|
|
11
|
+
|
|
12
|
+
herd sessions # what's running across all tabs
|
|
13
|
+
herd scrollback auth # read what auth is doing without switching tabs
|
|
14
|
+
herd send api --file task.txt # drop a prompt into any session
|
|
15
|
+
herd fork auth -n auth-v2 # branch a conversation, keep the original
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
No tmux. No dashboard. Your terminal tabs are the UI.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## The idea
|
|
23
|
+
|
|
24
|
+
When you're running multiple Claude Code sessions in parallel, you lose track fast. Which tab is working on what? Did it finish? Is it waiting for input?
|
|
25
|
+
|
|
26
|
+
herd solves this with a simple CLI that treats **terminal tabs as the unit of orchestration** — open them by name, read their output, send them prompts, fork them, close them. Everything stays in sync: the tab title, the Claude session name, and the working directory.
|
|
27
|
+
|
|
28
|
+
The killer feature: **Claude can run herd itself.** Install the skill and your Claude Code session can spawn parallel sibling sessions, monitor their output, and coordinate across them — without you switching tabs.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
**As a Claude Code plugin** (installs the CLI + skill in one step):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
/plugin marketplace add generativereality/plugins
|
|
36
|
+
/plugin install agentherder@generativereality
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Via npm** (CLI only):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install -g @generativereality/herd
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Skill only** (if you already have the CLI):
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
mkdir -p .claude/skills/herd
|
|
49
|
+
curl -fsSL https://raw.githubusercontent.com/generativereality/agentherder/main/skills/herd/SKILL.md \
|
|
50
|
+
-o .claude/skills/herd/SKILL.md
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Requirements:** [Wave Terminal](https://waveterm.dev) · macOS · Node.js 20+
|
|
54
|
+
|
|
55
|
+
**One-time:** Wave needs Accessibility permission — System Settings → Privacy & Security → Accessibility → Wave ✓
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
herd sessions what's running (active/idle status)
|
|
61
|
+
herd list all workspaces, tabs, and blocks
|
|
62
|
+
herd new <name> [dir] [-w workspace] open tab, start claude
|
|
63
|
+
herd resume <name> [dir] open tab, run claude --continue
|
|
64
|
+
herd fork <tab> [-n new-name] fork a session into a new tab
|
|
65
|
+
herd close <tab> close a tab
|
|
66
|
+
herd rename <tab> <new-name> rename a tab
|
|
67
|
+
herd scrollback <tab> [lines] read terminal output (default: 50 lines)
|
|
68
|
+
herd send <tab> [text] send input — arg, --file, or stdin pipe
|
|
69
|
+
herd config show config path and values
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Tab names match by prefix. Block IDs can be shortened to 8 chars.
|
|
73
|
+
|
|
74
|
+
### Spin up a session fleet
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
herd sessions # check what's already running first
|
|
78
|
+
|
|
79
|
+
herd new auth ~/Dev/myapp
|
|
80
|
+
herd new payments ~/Dev/myapp
|
|
81
|
+
herd new infra ~/Dev/myapp
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Each tab gets named, Claude's session name syncs to the tab title via `--name`.
|
|
85
|
+
|
|
86
|
+
### Send a prompt
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# From a file (good for long context-heavy prompts)
|
|
90
|
+
herd send auth --file ~/prompts/task.txt
|
|
91
|
+
|
|
92
|
+
# Via stdin
|
|
93
|
+
echo "focus on the edge cases in the OAuth flow" | herd send auth
|
|
94
|
+
|
|
95
|
+
# Quick reply or approval
|
|
96
|
+
herd send auth "yes\n"
|
|
97
|
+
herd send auth "/clear\n"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Check in without switching tabs
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
herd scrollback auth # last 50 lines
|
|
104
|
+
herd scrollback auth 200 # last 200 lines
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Fork a session
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# Try a different approach without losing the original conversation
|
|
111
|
+
herd fork auth -n auth-v2
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Runs `claude --resume <id> --fork-session` — new independent session, full shared context from the original.
|
|
115
|
+
|
|
116
|
+
### Target a workspace
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
herd new api ~/Dev/myapp -w work
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Claude Code Skill
|
|
123
|
+
|
|
124
|
+
The real unlock: install the plugin (see [Install](#install)) so **Claude Code can herd itself**.
|
|
125
|
+
|
|
126
|
+
With the skill installed, Claude can:
|
|
127
|
+
|
|
128
|
+
- Check what's running before starting duplicate work (`herd sessions`)
|
|
129
|
+
- Spawn a parallel session for an independent subtask (`herd new payments ~/Dev/myapp`)
|
|
130
|
+
- Monitor siblings without interrupting them (`herd scrollback payments`)
|
|
131
|
+
- Drop a prompt into any session (`herd send payments --file spec.txt`)
|
|
132
|
+
- Fork its own session to explore an alternative approach (`herd fork auth`)
|
|
133
|
+
|
|
134
|
+
Claude becomes the orchestrator of its own fleet.
|
|
135
|
+
|
|
136
|
+
## Tip: pair with Claude Code Remote Control
|
|
137
|
+
|
|
138
|
+
Claude Code's [Remote Control](https://docs.anthropic.com/en/docs/claude-code/remote-control) lets you access a local session from any device — phone, tablet, browser — via `claude.ai/code`. The session still runs on your machine, with full filesystem and tool access.
|
|
139
|
+
|
|
140
|
+
Paired with Agent Herder, the pattern is:
|
|
141
|
+
|
|
142
|
+
1. Start a **command session** with Remote Control enabled:
|
|
143
|
+
```bash
|
|
144
|
+
claude --remote-control "command"
|
|
145
|
+
```
|
|
146
|
+
2. From your phone or browser, connect to that session and assign work:
|
|
147
|
+
> *"Spawn three sessions — auth, payments, infra — and start them on these tasks..."*
|
|
148
|
+
3. The command session uses `herd` to open tabs, send prompts, and check in on workers
|
|
149
|
+
4. You monitor and steer the whole fleet from your phone while the machine does the work
|
|
150
|
+
|
|
151
|
+
One remote-controlled session orchestrating a local fleet.
|
|
152
|
+
|
|
153
|
+
## Config
|
|
154
|
+
|
|
155
|
+
```toml
|
|
156
|
+
# ~/.config/herd/config.toml
|
|
157
|
+
|
|
158
|
+
[claude]
|
|
159
|
+
# Flags passed to every claude invocation
|
|
160
|
+
flags = ["--allow-dangerously-skip-permissions"]
|
|
161
|
+
|
|
162
|
+
[defaults]
|
|
163
|
+
# Default Wave workspace for new sessions
|
|
164
|
+
# workspace = ""
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Terminal support
|
|
168
|
+
|
|
169
|
+
| Terminal | Status |
|
|
170
|
+
|----------|--------|
|
|
171
|
+
| [Wave Terminal](https://waveterm.dev) | ✅ Full support |
|
|
172
|
+
| iTerm2 | Planned |
|
|
173
|
+
| Ghostty | Planned |
|
|
174
|
+
| Warp | Planned |
|
|
175
|
+
|
|
176
|
+
Wave is supported via its unix socket RPC. Other terminals will follow as adapters — PRs welcome.
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import updateNotifier from "update-notifier";
|
|
3
|
+
import { cli, define } from "gunshi";
|
|
4
|
+
import { createConnection } from "net";
|
|
5
|
+
import { execFileSync, spawnSync } from "child_process";
|
|
6
|
+
import { randomUUID } from "crypto";
|
|
7
|
+
import { homedir } from "os";
|
|
8
|
+
import { basename, dirname, extname, join, resolve } from "path";
|
|
9
|
+
import { consola } from "consola";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
11
|
+
//#region package.json
|
|
12
|
+
var name = "@generativereality/herd";
|
|
13
|
+
var version = "0.1.0";
|
|
14
|
+
var description = "Agent session manager for AI coding tools. Terminal tabs as the UI, no tmux.";
|
|
15
|
+
var package_default = {
|
|
16
|
+
name,
|
|
17
|
+
version,
|
|
18
|
+
description,
|
|
19
|
+
type: "module",
|
|
20
|
+
bin: { "herd": "dist/index.js" },
|
|
21
|
+
files: ["dist", ".claude"],
|
|
22
|
+
scripts: {
|
|
23
|
+
"dev": "bun run ./src/index.ts",
|
|
24
|
+
"build": "tsdown",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"lint": "eslint src/",
|
|
27
|
+
"check": "bun run typecheck && bun run build",
|
|
28
|
+
"release": "bumpp && npm publish",
|
|
29
|
+
"prepack": "bun run build"
|
|
30
|
+
},
|
|
31
|
+
keywords: [
|
|
32
|
+
"claude-code",
|
|
33
|
+
"ai-agents",
|
|
34
|
+
"session-manager",
|
|
35
|
+
"wave-terminal",
|
|
36
|
+
"herd",
|
|
37
|
+
"agentherder"
|
|
38
|
+
],
|
|
39
|
+
author: "motin",
|
|
40
|
+
license: "MIT",
|
|
41
|
+
repository: {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "https://github.com/generativereality/agentherder"
|
|
44
|
+
},
|
|
45
|
+
homepage: "https://agentherder.com",
|
|
46
|
+
engines: { "node": ">=20.19.4" },
|
|
47
|
+
publishConfig: {
|
|
48
|
+
"registry": "https://registry.npmjs.org",
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
dependencies: {
|
|
52
|
+
"@clack/prompts": "^0.9.1",
|
|
53
|
+
"consola": "^3.4.0",
|
|
54
|
+
"gunshi": "^0.23.0",
|
|
55
|
+
"update-notifier": "^7.3.1"
|
|
56
|
+
},
|
|
57
|
+
devDependencies: {
|
|
58
|
+
"@types/node": "^22.0.0",
|
|
59
|
+
"@types/update-notifier": "^6.0.8",
|
|
60
|
+
"bumpp": "^9.11.1",
|
|
61
|
+
"tsdown": "^0.12.0",
|
|
62
|
+
"typescript": "^5.8.0"
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/core/terminal.ts
|
|
67
|
+
function detectTerminal() {
|
|
68
|
+
if (process.env.WAVETERM_JWT) return "wave";
|
|
69
|
+
const prog = process.env.TERM_PROGRAM ?? "";
|
|
70
|
+
const term = process.env.TERM ?? "";
|
|
71
|
+
if (prog === "iTerm.app") return "iterm2";
|
|
72
|
+
if (prog === "ghostty" || process.env.GHOSTTY_RESOURCES_DIR) return "ghostty";
|
|
73
|
+
if (prog === "WarpTerminal") return "warp";
|
|
74
|
+
if (prog === "vscode") return "vscode";
|
|
75
|
+
if (prog === "Hyper") return "hyper";
|
|
76
|
+
if (prog === "Apple_Terminal") return "apple-terminal";
|
|
77
|
+
if (term === "xterm-kitty" || process.env.KITTY_WINDOW_ID) return "kitty";
|
|
78
|
+
if (term === "alacritty") return "alacritty";
|
|
79
|
+
return "unknown";
|
|
80
|
+
}
|
|
81
|
+
const TERMINAL_NAMES = {
|
|
82
|
+
wave: "Wave Terminal",
|
|
83
|
+
iterm2: "iTerm2",
|
|
84
|
+
ghostty: "Ghostty",
|
|
85
|
+
warp: "Warp",
|
|
86
|
+
kitty: "Kitty",
|
|
87
|
+
vscode: "VS Code terminal",
|
|
88
|
+
hyper: "Hyper",
|
|
89
|
+
alacritty: "Alacritty",
|
|
90
|
+
"apple-terminal": "Terminal.app",
|
|
91
|
+
unknown: "an unrecognised terminal"
|
|
92
|
+
};
|
|
93
|
+
function printUnsupportedTerminalError(terminal) {
|
|
94
|
+
const name = TERMINAL_NAMES[terminal];
|
|
95
|
+
const lines = [
|
|
96
|
+
"",
|
|
97
|
+
` Agent Herder currently requires Wave Terminal.`,
|
|
98
|
+
` You appear to be running in: ${name}`,
|
|
99
|
+
"",
|
|
100
|
+
` Option 1 — Switch to Wave Terminal (full support today):`,
|
|
101
|
+
` brew install --cask wave`,
|
|
102
|
+
` https://waveterm.dev`,
|
|
103
|
+
"",
|
|
104
|
+
` Option 2 — Add ${name} support (one adapter file, PRs welcome):`,
|
|
105
|
+
` git clone https://github.com/generativereality/agentherder`,
|
|
106
|
+
` cd agentherder`,
|
|
107
|
+
` claude # ask Claude to implement the ${name} adapter`,
|
|
108
|
+
"",
|
|
109
|
+
` Claude will find src/core/wave.ts, use it as the reference`,
|
|
110
|
+
` implementation, create src/core/${adapterFileName(terminal)},`,
|
|
111
|
+
` wire it up, and open a PR — all in one session.`,
|
|
112
|
+
""
|
|
113
|
+
];
|
|
114
|
+
console.error(lines.join("\n"));
|
|
115
|
+
}
|
|
116
|
+
function adapterFileName(terminal) {
|
|
117
|
+
if (terminal === "unknown") return "<terminal>.ts";
|
|
118
|
+
return `${terminal}.ts`;
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/core/wave.ts
|
|
122
|
+
const SOCK_PATH = join(homedir(), "Library", "Application Support", "waveterm", "wave.sock");
|
|
123
|
+
var WaveSocket = class {
|
|
124
|
+
socket;
|
|
125
|
+
buffer = "";
|
|
126
|
+
pendingReaders = [];
|
|
127
|
+
routeId = "";
|
|
128
|
+
jwt;
|
|
129
|
+
constructor(jwt) {
|
|
130
|
+
this.jwt = jwt;
|
|
131
|
+
this.socket = createConnection(SOCK_PATH);
|
|
132
|
+
this.socket.on("data", (chunk) => {
|
|
133
|
+
this.buffer += chunk.toString();
|
|
134
|
+
let nl;
|
|
135
|
+
while ((nl = this.buffer.indexOf("\n")) !== -1) {
|
|
136
|
+
const line = this.buffer.slice(0, nl).trim();
|
|
137
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
138
|
+
if (!line) continue;
|
|
139
|
+
try {
|
|
140
|
+
const msg = JSON.parse(line);
|
|
141
|
+
this.pendingReaders.shift()?.(msg);
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
waitForMessage(timeoutMs = 8e3) {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
const idx = this.pendingReaders.indexOf(resolve);
|
|
150
|
+
if (idx !== -1) this.pendingReaders.splice(idx, 1);
|
|
151
|
+
reject(/* @__PURE__ */ new Error("Wave socket timeout"));
|
|
152
|
+
}, timeoutMs);
|
|
153
|
+
this.pendingReaders.push((msg) => {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
resolve(msg);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
send(msg) {
|
|
160
|
+
this.socket.write(JSON.stringify(msg) + "\n");
|
|
161
|
+
}
|
|
162
|
+
async connect() {
|
|
163
|
+
await new Promise((resolve, reject) => {
|
|
164
|
+
this.socket.once("connect", resolve);
|
|
165
|
+
this.socket.once("error", reject);
|
|
166
|
+
});
|
|
167
|
+
this.send({
|
|
168
|
+
command: "authenticate",
|
|
169
|
+
reqid: randomUUID(),
|
|
170
|
+
route: "$control",
|
|
171
|
+
data: this.jwt
|
|
172
|
+
});
|
|
173
|
+
this.routeId = (await this.waitForMessage()).data.routeid;
|
|
174
|
+
}
|
|
175
|
+
async command(command, data, route = "wavesrv") {
|
|
176
|
+
this.send({
|
|
177
|
+
command,
|
|
178
|
+
reqid: randomUUID(),
|
|
179
|
+
route,
|
|
180
|
+
source: this.routeId,
|
|
181
|
+
data
|
|
182
|
+
});
|
|
183
|
+
try {
|
|
184
|
+
return await this.waitForMessage();
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
destroy() {
|
|
190
|
+
this.socket.destroy();
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
var WaveAdapter = class {
|
|
194
|
+
socket = null;
|
|
195
|
+
jwt;
|
|
196
|
+
constructor() {
|
|
197
|
+
this.jwt = process.env.WAVETERM_JWT ?? "";
|
|
198
|
+
}
|
|
199
|
+
blocksList() {
|
|
200
|
+
try {
|
|
201
|
+
const out = execFileSync("wsh", [
|
|
202
|
+
"blocks",
|
|
203
|
+
"list",
|
|
204
|
+
"--json"
|
|
205
|
+
], { encoding: "utf-8" });
|
|
206
|
+
return JSON.parse(out);
|
|
207
|
+
} catch {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
scrollback(blockId, lastN = 50) {
|
|
212
|
+
return spawnSync("wsh", [
|
|
213
|
+
"termscrollback",
|
|
214
|
+
"-b",
|
|
215
|
+
blockId,
|
|
216
|
+
"--start",
|
|
217
|
+
`-${lastN}`
|
|
218
|
+
], { encoding: "utf-8" }).stdout ?? "";
|
|
219
|
+
}
|
|
220
|
+
deleteBlock(blockId) {
|
|
221
|
+
spawnSync("wsh", [
|
|
222
|
+
"deleteblock",
|
|
223
|
+
"-b",
|
|
224
|
+
blockId
|
|
225
|
+
], { encoding: "utf-8" });
|
|
226
|
+
}
|
|
227
|
+
async newTab(focusWindowId) {
|
|
228
|
+
if (focusWindowId) {
|
|
229
|
+
await this.focusWindow(focusWindowId);
|
|
230
|
+
await sleep(300);
|
|
231
|
+
}
|
|
232
|
+
const r = spawnSync("osascript", ["-e", [
|
|
233
|
+
"tell application \"Wave\" to activate",
|
|
234
|
+
"delay 0.25",
|
|
235
|
+
"tell application \"System Events\" to keystroke \"t\" using command down"
|
|
236
|
+
].join("\n")], { encoding: "utf-8" });
|
|
237
|
+
if (r.status !== 0) {
|
|
238
|
+
const msg = r.stderr?.trim();
|
|
239
|
+
throw new Error(msg ? `osascript failed: ${msg}` : "Failed to open new tab — ensure Wave Terminal has Accessibility permission:\n System Settings → Privacy & Security → Accessibility → Wave ✓");
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
async waitForNewBlock(beforeIds, timeoutMs = 5e3) {
|
|
244
|
+
const deadline = Date.now() + timeoutMs;
|
|
245
|
+
while (Date.now() < deadline) {
|
|
246
|
+
await sleep(250);
|
|
247
|
+
for (const b of this.blocksList()) if (b.view === "term" && !beforeIds.has(b.blockid)) return {
|
|
248
|
+
blockId: b.blockid,
|
|
249
|
+
tabId: b.tabid
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
async sock() {
|
|
255
|
+
if (!this.socket) {
|
|
256
|
+
const s = new WaveSocket(this.jwt);
|
|
257
|
+
await s.connect();
|
|
258
|
+
this.socket = s;
|
|
259
|
+
}
|
|
260
|
+
return this.socket;
|
|
261
|
+
}
|
|
262
|
+
closeSocket() {
|
|
263
|
+
this.socket?.destroy();
|
|
264
|
+
this.socket = null;
|
|
265
|
+
}
|
|
266
|
+
async getTab(tabId) {
|
|
267
|
+
return (await (await this.sock()).command("gettab", tabId))?.data ?? {};
|
|
268
|
+
}
|
|
269
|
+
async workspaceList() {
|
|
270
|
+
return (await (await this.sock()).command("workspacelist", null))?.data ?? [];
|
|
271
|
+
}
|
|
272
|
+
async focusWindow(windowId) {
|
|
273
|
+
await (await this.sock()).command("focuswindow", windowId, "electron");
|
|
274
|
+
}
|
|
275
|
+
async renameTab(tabId, name) {
|
|
276
|
+
await (await this.sock()).command("updatetabname", { args: [tabId, name] });
|
|
277
|
+
}
|
|
278
|
+
async sendInput(blockId, text) {
|
|
279
|
+
const s = await this.sock();
|
|
280
|
+
const inputdata64 = Buffer.from(text).toString("base64");
|
|
281
|
+
return s.command("controllerinput", {
|
|
282
|
+
blockid: blockId,
|
|
283
|
+
inputdata64
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
async getAllData() {
|
|
287
|
+
const blocks = this.blocksList();
|
|
288
|
+
const tabsById = /* @__PURE__ */ new Map();
|
|
289
|
+
for (const b of blocks) {
|
|
290
|
+
const arr = tabsById.get(b.tabid) ?? [];
|
|
291
|
+
arr.push(b);
|
|
292
|
+
tabsById.set(b.tabid, arr);
|
|
293
|
+
}
|
|
294
|
+
const tabNames = /* @__PURE__ */ new Map();
|
|
295
|
+
let workspaces = [];
|
|
296
|
+
try {
|
|
297
|
+
for (const tabId of tabsById.keys()) {
|
|
298
|
+
const td = await this.getTab(tabId);
|
|
299
|
+
tabNames.set(tabId, td.name ?? tabId.slice(0, 8));
|
|
300
|
+
}
|
|
301
|
+
workspaces = await this.workspaceList();
|
|
302
|
+
} catch {} finally {
|
|
303
|
+
this.closeSocket();
|
|
304
|
+
}
|
|
305
|
+
if (!workspaces.length) {
|
|
306
|
+
const wsId = process.env.WAVETERM_WORKSPACEID ?? "";
|
|
307
|
+
workspaces = [{
|
|
308
|
+
workspacedata: {
|
|
309
|
+
oid: wsId,
|
|
310
|
+
name: wsId.slice(0, 8) || "default",
|
|
311
|
+
tabids: [...tabsById.keys()]
|
|
312
|
+
},
|
|
313
|
+
windowid: ""
|
|
314
|
+
}];
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
blocks,
|
|
318
|
+
tabsById,
|
|
319
|
+
workspaces,
|
|
320
|
+
tabNames
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
resolveTab(query, tabsById, tabNames) {
|
|
324
|
+
const q = query.toLowerCase();
|
|
325
|
+
return [...tabsById.keys()].filter((tid) => {
|
|
326
|
+
const name = tabNames.get(tid) ?? "";
|
|
327
|
+
return name.toLowerCase() === q || tid.startsWith(query) || name.toLowerCase().startsWith(q);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
resolveBlock(query, blocks) {
|
|
331
|
+
return blocks.filter((b) => b.blockid.startsWith(query));
|
|
332
|
+
}
|
|
333
|
+
resolveWorkspace(workspaces, query) {
|
|
334
|
+
const q = query.toLowerCase();
|
|
335
|
+
return workspaces.filter(({ workspacedata: wd }) => {
|
|
336
|
+
const name = wd.name ?? "";
|
|
337
|
+
return name.toLowerCase() === q || wd.oid.startsWith(query) || name.toLowerCase().startsWith(q);
|
|
338
|
+
}).map((w) => ({
|
|
339
|
+
data: w.workspacedata,
|
|
340
|
+
windowId: w.windowid
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
function requireWaveAdapter() {
|
|
345
|
+
if (!process.env.WAVETERM_JWT) {
|
|
346
|
+
printUnsupportedTerminalError(detectTerminal());
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
return new WaveAdapter();
|
|
350
|
+
}
|
|
351
|
+
function sleep(ms) {
|
|
352
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
353
|
+
}
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region src/commands/sessions.ts
|
|
356
|
+
const sessionsCommand = define({
|
|
357
|
+
name: "sessions",
|
|
358
|
+
description: "List tabs with active/idle session status",
|
|
359
|
+
args: {},
|
|
360
|
+
async run() {
|
|
361
|
+
const adapter = requireWaveAdapter();
|
|
362
|
+
const { tabsById, workspaces, tabNames } = await adapter.getAllData();
|
|
363
|
+
const currentTab = process.env.WAVETERM_TABID ?? "";
|
|
364
|
+
const currentWs = process.env.WAVETERM_WORKSPACEID ?? "";
|
|
365
|
+
console.log("Sessions");
|
|
366
|
+
console.log("=".repeat(50));
|
|
367
|
+
for (const wsp of workspaces) {
|
|
368
|
+
const { oid, name, tabids } = wsp.workspacedata;
|
|
369
|
+
const wsMarker = oid === currentWs ? " (current)" : "";
|
|
370
|
+
const tabIds = tabids.filter((t) => tabsById.has(t));
|
|
371
|
+
if (!tabIds.length) continue;
|
|
372
|
+
console.log(`\nWorkspace: ${name}${wsMarker}`);
|
|
373
|
+
for (const tabId of tabIds) {
|
|
374
|
+
const termBlocks = (tabsById.get(tabId) ?? []).filter((b) => b.view === "term");
|
|
375
|
+
if (!termBlocks.length) continue;
|
|
376
|
+
const name = tabNames.get(tabId) ?? tabId.slice(0, 8);
|
|
377
|
+
const cur = tabId === currentTab ? " ◄" : "";
|
|
378
|
+
const b = termBlocks[0];
|
|
379
|
+
const cwd = (b.meta?.["cmd:cwd"] ?? "").replace(process.env.HOME ?? "", "~");
|
|
380
|
+
const tail = adapter.scrollback(b.blockid, 5);
|
|
381
|
+
const lastLine = tail.split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
|
|
382
|
+
let status = "terminal";
|
|
383
|
+
if ([
|
|
384
|
+
"Claude Code",
|
|
385
|
+
"claude.ai/code",
|
|
386
|
+
"✻ Thinking",
|
|
387
|
+
"✽ Hatching",
|
|
388
|
+
"⏵⏵ bypass"
|
|
389
|
+
].some((s) => tail.includes(s))) status = "active";
|
|
390
|
+
else if (lastLine.toLowerCase().includes("claude")) status = "idle";
|
|
391
|
+
const statusLabel = status === "active" ? "● active" : status === "idle" ? "○ idle" : " terminal";
|
|
392
|
+
console.log(` [${tabId.slice(0, 8)}] "${name}"${cur} ${cwd}`);
|
|
393
|
+
console.log(` ${statusLabel}`);
|
|
394
|
+
if (status === "terminal" && lastLine) console.log(` last: ${lastLine.slice(0, 80)}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
//#endregion
|
|
400
|
+
//#region src/commands/list.ts
|
|
401
|
+
const listCommand = define({
|
|
402
|
+
name: "list",
|
|
403
|
+
description: "List all workspaces, tabs, and blocks",
|
|
404
|
+
args: {},
|
|
405
|
+
async run() {
|
|
406
|
+
const { tabsById, workspaces, tabNames } = await requireWaveAdapter().getAllData();
|
|
407
|
+
const currentBlock = process.env.WAVETERM_BLOCKID ?? "";
|
|
408
|
+
const currentTab = process.env.WAVETERM_TABID ?? "";
|
|
409
|
+
const currentWs = process.env.WAVETERM_WORKSPACEID ?? "";
|
|
410
|
+
for (const wsp of workspaces) {
|
|
411
|
+
const { oid, name, tabids } = wsp.workspacedata;
|
|
412
|
+
const noWindow = !wsp.windowid ? " (no window)" : "";
|
|
413
|
+
const wsMarker = oid === currentWs ? " ◄ current" : noWindow;
|
|
414
|
+
console.log(`Workspace: ${name} [${oid.slice(0, 8)}]${wsMarker}`);
|
|
415
|
+
console.log();
|
|
416
|
+
const tabIds = tabids.filter((t) => tabsById.has(t));
|
|
417
|
+
if (!tabIds.length) {
|
|
418
|
+
console.log(" (no open tabs)");
|
|
419
|
+
console.log();
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
for (const tabId of tabIds) {
|
|
423
|
+
const tabName = tabNames.get(tabId) ?? tabId.slice(0, 8);
|
|
424
|
+
const cur = tabId === currentTab ? " ◄" : "";
|
|
425
|
+
console.log(` Tab "${tabName}" [${tabId.slice(0, 8)}]${cur}`);
|
|
426
|
+
for (const b of tabsById.get(tabId) ?? []) {
|
|
427
|
+
const here = b.blockid === currentBlock ? " ◄ here" : "";
|
|
428
|
+
const cwd = b.meta?.["cmd:cwd"] ?? "";
|
|
429
|
+
console.log(` ${b.view.padEnd(8)} ${b.blockid.slice(0, 8)}${cwd ? ` ${cwd}` : ""}${here}`);
|
|
430
|
+
}
|
|
431
|
+
console.log();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
//#endregion
|
|
437
|
+
//#region src/core/config.ts
|
|
438
|
+
const CONFIG_PATH = join(homedir(), ".config", "herd", "config.toml");
|
|
439
|
+
const DEFAULT_CONFIG = {
|
|
440
|
+
claude: { flags: ["--allow-dangerously-skip-permissions"] },
|
|
441
|
+
defaults: { workspace: "" }
|
|
442
|
+
};
|
|
443
|
+
const DEFAULT_CONFIG_FILE = `# herd configuration
|
|
444
|
+
# https://agentherder.com
|
|
445
|
+
|
|
446
|
+
[claude]
|
|
447
|
+
# Extra flags passed to every \`claude\` invocation.
|
|
448
|
+
flags = ["--allow-dangerously-skip-permissions"]
|
|
449
|
+
|
|
450
|
+
[defaults]
|
|
451
|
+
# Default Wave workspace to open new sessions in.
|
|
452
|
+
# workspace = ""
|
|
453
|
+
`;
|
|
454
|
+
function parseToml(text) {
|
|
455
|
+
const result = {};
|
|
456
|
+
let section = null;
|
|
457
|
+
for (const raw of text.split("\n")) {
|
|
458
|
+
const line = raw.trim();
|
|
459
|
+
if (!line || line.startsWith("#")) continue;
|
|
460
|
+
if (line.startsWith("[") && line.endsWith("]")) {
|
|
461
|
+
section = line.slice(1, -1).trim();
|
|
462
|
+
result[section] ??= {};
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (section && line.includes("=")) {
|
|
466
|
+
const [rawKey, ...rest] = line.split("=");
|
|
467
|
+
const key = rawKey.trim();
|
|
468
|
+
const val = rest.join("=").trim();
|
|
469
|
+
if (val.startsWith("[")) {
|
|
470
|
+
const items = [...val.matchAll(/"([^"]*)"/g)].map((m) => m[1]);
|
|
471
|
+
result[section][key] = items;
|
|
472
|
+
} else if (val.startsWith("\"") && val.endsWith("\"")) result[section][key] = val.slice(1, -1);
|
|
473
|
+
else if (val === "true" || val === "false") result[section][key] = val === "true";
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
function loadConfig() {
|
|
479
|
+
const config = {
|
|
480
|
+
claude: { ...DEFAULT_CONFIG.claude },
|
|
481
|
+
defaults: { ...DEFAULT_CONFIG.defaults }
|
|
482
|
+
};
|
|
483
|
+
if (!existsSync(CONFIG_PATH)) return config;
|
|
484
|
+
try {
|
|
485
|
+
const parsed = parseToml(readFileSync(CONFIG_PATH, "utf-8"));
|
|
486
|
+
if (parsed.claude) Object.assign(config.claude, parsed.claude);
|
|
487
|
+
if (parsed.defaults) Object.assign(config.defaults, parsed.defaults);
|
|
488
|
+
} catch {}
|
|
489
|
+
return config;
|
|
490
|
+
}
|
|
491
|
+
function ensureConfigExists() {
|
|
492
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
493
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
494
|
+
writeFileSync(CONFIG_PATH, DEFAULT_CONFIG_FILE);
|
|
495
|
+
}
|
|
496
|
+
return CONFIG_PATH;
|
|
497
|
+
}
|
|
498
|
+
//#endregion
|
|
499
|
+
//#region src/core/open-session.ts
|
|
500
|
+
async function openSession(opts) {
|
|
501
|
+
const { tabName, claudeCmd, workspaceQuery } = opts;
|
|
502
|
+
const dir = resolve(opts.dir.replace(/^~/, homedir()));
|
|
503
|
+
const config = loadConfig();
|
|
504
|
+
const adapter = requireWaveAdapter();
|
|
505
|
+
let focusWindowId;
|
|
506
|
+
if (workspaceQuery) {
|
|
507
|
+
const { workspaces } = await adapter.getAllData();
|
|
508
|
+
const matches = adapter.resolveWorkspace(workspaces, workspaceQuery);
|
|
509
|
+
if (!matches.length) {
|
|
510
|
+
consola.error(`No workspace matching '${workspaceQuery}'`);
|
|
511
|
+
process.exit(1);
|
|
512
|
+
}
|
|
513
|
+
const { data, windowId } = matches[0];
|
|
514
|
+
if (!windowId) {
|
|
515
|
+
consola.error(`Workspace '${data.name}' has no open window`);
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
focusWindowId = windowId;
|
|
519
|
+
consola.info(`Workspace: ${data.name}`);
|
|
520
|
+
}
|
|
521
|
+
const beforeIds = new Set(adapter.blocksList().filter((b) => b.view === "term").map((b) => b.blockid));
|
|
522
|
+
await adapter.newTab(focusWindowId);
|
|
523
|
+
const result = await adapter.waitForNewBlock(beforeIds);
|
|
524
|
+
if (!result) {
|
|
525
|
+
consola.error("Timed out waiting for new terminal block");
|
|
526
|
+
process.exit(1);
|
|
527
|
+
}
|
|
528
|
+
const { blockId, tabId } = result;
|
|
529
|
+
await adapter.renameTab(tabId, tabName);
|
|
530
|
+
const extraFlags = config.claude.flags.join(" ");
|
|
531
|
+
const cmd = `cd ${JSON.stringify(dir)} && ${claudeCmd} --name ${JSON.stringify(tabName)}${extraFlags ? " " + extraFlags : ""}\n`;
|
|
532
|
+
await adapter.sendInput(blockId, cmd);
|
|
533
|
+
adapter.closeSocket();
|
|
534
|
+
return tabId;
|
|
535
|
+
}
|
|
536
|
+
//#endregion
|
|
537
|
+
//#region src/commands/new.ts
|
|
538
|
+
const newCommand = define({
|
|
539
|
+
name: "new",
|
|
540
|
+
description: "Open a new tab and launch claude",
|
|
541
|
+
args: {
|
|
542
|
+
name: {
|
|
543
|
+
type: "positional",
|
|
544
|
+
description: "Tab name"
|
|
545
|
+
},
|
|
546
|
+
dir: {
|
|
547
|
+
type: "positional",
|
|
548
|
+
description: "Working directory (default: cwd)"
|
|
549
|
+
},
|
|
550
|
+
workspace: {
|
|
551
|
+
type: "string",
|
|
552
|
+
short: "w",
|
|
553
|
+
description: "Target workspace"
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
async run(ctx) {
|
|
557
|
+
const name = ctx.positionals[1];
|
|
558
|
+
const dir = ctx.positionals[2] ?? process.cwd();
|
|
559
|
+
const workspace = ctx.values.workspace;
|
|
560
|
+
if (!name) {
|
|
561
|
+
consola.error("Tab name is required");
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
const tabId = await openSession({
|
|
565
|
+
tabName: name,
|
|
566
|
+
dir,
|
|
567
|
+
claudeCmd: "claude",
|
|
568
|
+
workspaceQuery: workspace
|
|
569
|
+
});
|
|
570
|
+
consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude at ${dir}`);
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region src/commands/resume.ts
|
|
575
|
+
const resumeCommand = define({
|
|
576
|
+
name: "resume",
|
|
577
|
+
description: "Open a new tab and run: claude --continue",
|
|
578
|
+
args: {
|
|
579
|
+
name: {
|
|
580
|
+
type: "positional",
|
|
581
|
+
description: "Tab name"
|
|
582
|
+
},
|
|
583
|
+
dir: {
|
|
584
|
+
type: "positional",
|
|
585
|
+
description: "Working directory (default: cwd)"
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
async run(ctx) {
|
|
589
|
+
const name = ctx.positionals[1];
|
|
590
|
+
const dir = ctx.positionals[2] ?? process.cwd();
|
|
591
|
+
if (!name) {
|
|
592
|
+
consola.error("Tab name is required");
|
|
593
|
+
process.exit(1);
|
|
594
|
+
}
|
|
595
|
+
const tabId = await openSession({
|
|
596
|
+
tabName: name,
|
|
597
|
+
dir,
|
|
598
|
+
claudeCmd: "claude --continue"
|
|
599
|
+
});
|
|
600
|
+
consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude --continue at ${dir}`);
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
//#endregion
|
|
604
|
+
//#region src/core/session.ts
|
|
605
|
+
/** Convert an absolute path to Claude's project slug (/ → -) */
|
|
606
|
+
function pathToProjectSlug(dir) {
|
|
607
|
+
return resolve(dir).replace(/\//g, "-");
|
|
608
|
+
}
|
|
609
|
+
/** Find the most recent Claude Code session ID for a directory */
|
|
610
|
+
function findLatestSessionId(dir) {
|
|
611
|
+
const slug = pathToProjectSlug(dir);
|
|
612
|
+
const projectDir = join(homedir(), ".claude", "projects", slug);
|
|
613
|
+
if (!existsSync(projectDir)) return null;
|
|
614
|
+
const jsonlFiles = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl").map((f) => ({
|
|
615
|
+
name: f,
|
|
616
|
+
mtime: statSync(join(projectDir, f)).mtimeMs
|
|
617
|
+
})).sort((a, b) => b.mtime - a.mtime);
|
|
618
|
+
if (!jsonlFiles.length) return null;
|
|
619
|
+
return basename(jsonlFiles[0].name, ".jsonl");
|
|
620
|
+
}
|
|
621
|
+
//#endregion
|
|
622
|
+
//#region src/commands/fork.ts
|
|
623
|
+
const forkCommand = define({
|
|
624
|
+
name: "fork",
|
|
625
|
+
description: "Fork a session into a new tab (claude --resume <id> --fork-session)",
|
|
626
|
+
args: {
|
|
627
|
+
tab: {
|
|
628
|
+
type: "positional",
|
|
629
|
+
description: "Source tab name or ID prefix"
|
|
630
|
+
},
|
|
631
|
+
name: {
|
|
632
|
+
type: "string",
|
|
633
|
+
short: "n",
|
|
634
|
+
description: "Name for the new tab"
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
async run(ctx) {
|
|
638
|
+
const sourceQuery = ctx.positionals[1];
|
|
639
|
+
const customName = ctx.values.name;
|
|
640
|
+
if (!sourceQuery) {
|
|
641
|
+
consola.error("Source tab name is required");
|
|
642
|
+
process.exit(1);
|
|
643
|
+
}
|
|
644
|
+
const adapter = requireWaveAdapter();
|
|
645
|
+
const { tabsById, tabNames } = await adapter.getAllData();
|
|
646
|
+
const matches = adapter.resolveTab(sourceQuery, tabsById, tabNames);
|
|
647
|
+
if (!matches.length) {
|
|
648
|
+
consola.error(`No tab matching '${sourceQuery}'`);
|
|
649
|
+
process.exit(1);
|
|
650
|
+
}
|
|
651
|
+
if (matches.length > 1) {
|
|
652
|
+
consola.error(`Multiple tabs match '${sourceQuery}':`);
|
|
653
|
+
for (const tid of matches) consola.log(` "${tabNames.get(tid)}" [${tid.slice(0, 8)}]`);
|
|
654
|
+
process.exit(1);
|
|
655
|
+
}
|
|
656
|
+
const tabId = matches[0];
|
|
657
|
+
const tabName = tabNames.get(tabId) ?? tabId.slice(0, 8);
|
|
658
|
+
const newName = customName ?? `${tabName}-fork`;
|
|
659
|
+
const termBlocks = (tabsById.get(tabId) ?? []).filter((b) => b.view === "term");
|
|
660
|
+
if (!termBlocks.length) {
|
|
661
|
+
consola.error(`Tab "${tabName}" has no terminal block`);
|
|
662
|
+
process.exit(1);
|
|
663
|
+
}
|
|
664
|
+
const sourceDir = termBlocks[0].meta?.["cmd:cwd"] ?? process.cwd();
|
|
665
|
+
const sessionId = findLatestSessionId(sourceDir);
|
|
666
|
+
if (!sessionId) {
|
|
667
|
+
consola.error(`No Claude session found for ${sourceDir}`);
|
|
668
|
+
consola.info(`Looked in ~/.claude/projects/${pathToProjectSlug(sourceDir)}/`);
|
|
669
|
+
process.exit(1);
|
|
670
|
+
}
|
|
671
|
+
const newTabId = await openSession({
|
|
672
|
+
tabName: newName,
|
|
673
|
+
dir: sourceDir,
|
|
674
|
+
claudeCmd: `claude --resume ${sessionId} --fork-session`
|
|
675
|
+
});
|
|
676
|
+
consola.success(`Forked "${tabName}" → "${newName}" [${newTabId.slice(0, 8)}]`);
|
|
677
|
+
consola.info(`session: ${sessionId}`);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
//#endregion
|
|
681
|
+
//#region src/commands/close.ts
|
|
682
|
+
const closeCommand = define({
|
|
683
|
+
name: "close",
|
|
684
|
+
description: "Close a tab by name or ID prefix",
|
|
685
|
+
args: { tab: {
|
|
686
|
+
type: "positional",
|
|
687
|
+
description: "Tab name or ID prefix"
|
|
688
|
+
} },
|
|
689
|
+
async run(ctx) {
|
|
690
|
+
const query = ctx.positionals[1];
|
|
691
|
+
if (!query) {
|
|
692
|
+
consola.error("Tab name or ID is required");
|
|
693
|
+
process.exit(1);
|
|
694
|
+
}
|
|
695
|
+
const adapter = requireWaveAdapter();
|
|
696
|
+
const { tabsById, tabNames } = await adapter.getAllData();
|
|
697
|
+
const matches = adapter.resolveTab(query, tabsById, tabNames);
|
|
698
|
+
if (!matches.length) {
|
|
699
|
+
consola.error(`No tab matching '${query}'`);
|
|
700
|
+
process.exit(1);
|
|
701
|
+
}
|
|
702
|
+
if (matches.length > 1) {
|
|
703
|
+
consola.error(`Multiple tabs match '${query}':`);
|
|
704
|
+
for (const tid of matches) consola.log(` "${tabNames.get(tid)}" [${tid.slice(0, 8)}]`);
|
|
705
|
+
process.exit(1);
|
|
706
|
+
}
|
|
707
|
+
const tabId = matches[0];
|
|
708
|
+
const name = tabNames.get(tabId) ?? tabId.slice(0, 8);
|
|
709
|
+
for (const b of tabsById.get(tabId) ?? []) adapter.deleteBlock(b.blockid);
|
|
710
|
+
adapter.closeSocket();
|
|
711
|
+
consola.success(`Closed "${name}" [${tabId.slice(0, 8)}]`);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
//#endregion
|
|
715
|
+
//#region src/commands/rename.ts
|
|
716
|
+
const renameCommand = define({
|
|
717
|
+
name: "rename",
|
|
718
|
+
description: "Rename a tab",
|
|
719
|
+
args: {
|
|
720
|
+
tab: {
|
|
721
|
+
type: "positional",
|
|
722
|
+
description: "Tab name or ID prefix"
|
|
723
|
+
},
|
|
724
|
+
newName: {
|
|
725
|
+
type: "positional",
|
|
726
|
+
description: "New name"
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
async run(ctx) {
|
|
730
|
+
const query = ctx.positionals[1];
|
|
731
|
+
const newName = ctx.positionals[2];
|
|
732
|
+
if (!query || !newName) {
|
|
733
|
+
consola.error("Usage: herd rename <tab> <new-name>");
|
|
734
|
+
process.exit(1);
|
|
735
|
+
}
|
|
736
|
+
const adapter = requireWaveAdapter();
|
|
737
|
+
const { tabsById, tabNames } = await adapter.getAllData();
|
|
738
|
+
const matches = adapter.resolveTab(query, tabsById, tabNames);
|
|
739
|
+
if (!matches.length) {
|
|
740
|
+
consola.error(`No tab matching '${query}'`);
|
|
741
|
+
process.exit(1);
|
|
742
|
+
}
|
|
743
|
+
if (matches.length > 1) {
|
|
744
|
+
consola.error(`Multiple tabs match '${query}':`);
|
|
745
|
+
for (const tid of matches) consola.log(` "${tabNames.get(tid)}" [${tid.slice(0, 8)}]`);
|
|
746
|
+
process.exit(1);
|
|
747
|
+
}
|
|
748
|
+
const oldName = tabNames.get(matches[0]) ?? matches[0].slice(0, 8);
|
|
749
|
+
await adapter.renameTab(matches[0], newName);
|
|
750
|
+
adapter.closeSocket();
|
|
751
|
+
consola.success(`Renamed "${oldName}" → "${newName}"`);
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
//#endregion
|
|
755
|
+
//#region src/commands/scrollback.ts
|
|
756
|
+
const scrollbackCommand = define({
|
|
757
|
+
name: "scrollback",
|
|
758
|
+
description: "Show terminal output for a block (default: last 50 lines)",
|
|
759
|
+
args: {
|
|
760
|
+
block: {
|
|
761
|
+
type: "positional",
|
|
762
|
+
description: "Block ID prefix"
|
|
763
|
+
},
|
|
764
|
+
lines: {
|
|
765
|
+
type: "number",
|
|
766
|
+
description: "Number of lines to show",
|
|
767
|
+
default: 50
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
async run(ctx) {
|
|
771
|
+
const query = ctx.positionals[1];
|
|
772
|
+
const lines = ctx.values.lines ?? 50;
|
|
773
|
+
if (!query) {
|
|
774
|
+
consola.error("Block ID is required");
|
|
775
|
+
process.exit(1);
|
|
776
|
+
}
|
|
777
|
+
const adapter = requireWaveAdapter();
|
|
778
|
+
const blocks = adapter.blocksList();
|
|
779
|
+
const matches = adapter.resolveBlock(query, blocks);
|
|
780
|
+
if (!matches.length) {
|
|
781
|
+
consola.error(`No block matching '${query}'`);
|
|
782
|
+
process.exit(1);
|
|
783
|
+
}
|
|
784
|
+
if (matches.length > 1) {
|
|
785
|
+
consola.error(`Multiple blocks match '${query}':`);
|
|
786
|
+
for (const b of matches) consola.log(` ${b.blockid}`);
|
|
787
|
+
process.exit(1);
|
|
788
|
+
}
|
|
789
|
+
process.stdout.write(adapter.scrollback(matches[0].blockid, lines));
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
//#endregion
|
|
793
|
+
//#region src/commands/send.ts
|
|
794
|
+
function readStdin() {
|
|
795
|
+
return new Promise((resolve) => {
|
|
796
|
+
const chunks = [];
|
|
797
|
+
process.stdin.on("data", (c) => chunks.push(c));
|
|
798
|
+
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
const sendCommand = define({
|
|
802
|
+
name: "send",
|
|
803
|
+
description: "Send input to a tab or block (text arg, --file, or stdin pipe)",
|
|
804
|
+
args: {
|
|
805
|
+
target: {
|
|
806
|
+
type: "positional",
|
|
807
|
+
description: "Tab name, tab ID prefix, or block ID prefix"
|
|
808
|
+
},
|
|
809
|
+
file: {
|
|
810
|
+
type: "string",
|
|
811
|
+
short: "f",
|
|
812
|
+
description: "Read text from file"
|
|
813
|
+
},
|
|
814
|
+
enter: {
|
|
815
|
+
type: "boolean",
|
|
816
|
+
short: "e",
|
|
817
|
+
description: "Append newline after text (default: true)"
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
async run(ctx) {
|
|
821
|
+
const query = ctx.positionals[1];
|
|
822
|
+
const inlineText = ctx.positionals[2];
|
|
823
|
+
const filePath = ctx.values.file;
|
|
824
|
+
const appendEnter = ctx.values.enter ?? true;
|
|
825
|
+
if (!query) {
|
|
826
|
+
consola.error("Usage: herd send <tab-or-block> [text]");
|
|
827
|
+
process.exit(1);
|
|
828
|
+
}
|
|
829
|
+
let rawText;
|
|
830
|
+
if (inlineText !== void 0) rawText = inlineText.replace(/\\n/g, "\n").replace(/\\t/g, " ");
|
|
831
|
+
else if (filePath) rawText = readFileSync(filePath, "utf-8");
|
|
832
|
+
else rawText = await readStdin();
|
|
833
|
+
if (appendEnter && !rawText.endsWith("\n")) rawText += "\n";
|
|
834
|
+
const adapter = requireWaveAdapter();
|
|
835
|
+
const { tabsById, tabNames } = await adapter.getAllData();
|
|
836
|
+
const tabMatches = adapter.resolveTab(query, tabsById, tabNames);
|
|
837
|
+
let blockId;
|
|
838
|
+
if (tabMatches.length === 1) {
|
|
839
|
+
const blocks = (tabsById.get(tabMatches[0]) ?? []).filter((b) => b.view === "term");
|
|
840
|
+
if (!blocks.length) {
|
|
841
|
+
consola.error(`Tab "${tabNames.get(tabMatches[0])}" has no terminal block`);
|
|
842
|
+
process.exit(1);
|
|
843
|
+
}
|
|
844
|
+
blockId = blocks[0].blockid;
|
|
845
|
+
} else if (tabMatches.length > 1) {
|
|
846
|
+
consola.error(`Multiple tabs match '${query}':`);
|
|
847
|
+
for (const tid of tabMatches) consola.log(` "${tabNames.get(tid)}" [${tid.slice(0, 8)}]`);
|
|
848
|
+
process.exit(1);
|
|
849
|
+
} else {
|
|
850
|
+
const allBlocks = adapter.blocksList();
|
|
851
|
+
const blockMatches = adapter.resolveBlock(query, allBlocks);
|
|
852
|
+
if (!blockMatches.length) {
|
|
853
|
+
consola.error(`No tab or block matching '${query}'`);
|
|
854
|
+
process.exit(1);
|
|
855
|
+
}
|
|
856
|
+
if (blockMatches.length > 1) {
|
|
857
|
+
consola.error(`Multiple blocks match '${query}':`);
|
|
858
|
+
for (const b of blockMatches) consola.log(` ${b.blockid}`);
|
|
859
|
+
process.exit(1);
|
|
860
|
+
}
|
|
861
|
+
blockId = blockMatches[0].blockid;
|
|
862
|
+
}
|
|
863
|
+
const resp = await adapter.sendInput(blockId, rawText);
|
|
864
|
+
adapter.closeSocket();
|
|
865
|
+
if (resp && resp.error) {
|
|
866
|
+
consola.error(String(resp.error));
|
|
867
|
+
process.exit(1);
|
|
868
|
+
}
|
|
869
|
+
const preview = rawText.slice(0, 80).replace(/\n/g, "↵").replace(/\t/g, "→");
|
|
870
|
+
consola.success(`Sent to ${blockId.slice(0, 8)}: ${JSON.stringify(preview)}${rawText.length > 80 ? "…" : ""}`);
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
//#endregion
|
|
874
|
+
//#region src/commands/config-cmd.ts
|
|
875
|
+
const configCommand = define({
|
|
876
|
+
name: "config",
|
|
877
|
+
description: "Show config file path and current values",
|
|
878
|
+
args: {},
|
|
879
|
+
async run() {
|
|
880
|
+
ensureConfigExists();
|
|
881
|
+
const config = loadConfig();
|
|
882
|
+
consola.info(`Config: ${CONFIG_PATH}`);
|
|
883
|
+
console.log();
|
|
884
|
+
console.log(`claude.flags = ${config.claude.flags.length ? JSON.stringify(config.claude.flags) : "(none)"}`);
|
|
885
|
+
console.log(`defaults.workspace = ${config.defaults.workspace || "(none)"}`);
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
//#endregion
|
|
889
|
+
//#region src/commands/index.ts
|
|
890
|
+
const defaultCommand = define({
|
|
891
|
+
name: "herd",
|
|
892
|
+
description,
|
|
893
|
+
args: {},
|
|
894
|
+
async run() {
|
|
895
|
+
await sessionsCommand.run?.call(this, { args: {} });
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
const subCommands = new Map([
|
|
899
|
+
["sessions", sessionsCommand],
|
|
900
|
+
["list", listCommand],
|
|
901
|
+
["ls", listCommand],
|
|
902
|
+
["new", newCommand],
|
|
903
|
+
["resume", resumeCommand],
|
|
904
|
+
["fork", forkCommand],
|
|
905
|
+
["close", closeCommand],
|
|
906
|
+
["rename", renameCommand],
|
|
907
|
+
["scrollback", scrollbackCommand],
|
|
908
|
+
["send", sendCommand],
|
|
909
|
+
["config", configCommand]
|
|
910
|
+
]);
|
|
911
|
+
async function run() {
|
|
912
|
+
await cli(process.argv.slice(2), defaultCommand, {
|
|
913
|
+
name,
|
|
914
|
+
version,
|
|
915
|
+
description,
|
|
916
|
+
subCommands
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
//#endregion
|
|
920
|
+
//#region src/index.ts
|
|
921
|
+
updateNotifier({ pkg: package_default }).notify();
|
|
922
|
+
run().catch((err) => {
|
|
923
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
924
|
+
process.exit(1);
|
|
925
|
+
});
|
|
926
|
+
//#endregion
|
|
927
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@generativereality/herd",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Agent session manager for AI coding tools. Terminal tabs as the UI, no tmux.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"herd": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
".claude"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "bun run ./src/index.ts",
|
|
15
|
+
"build": "tsdown",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"lint": "eslint src/",
|
|
18
|
+
"check": "bun run typecheck && bun run build",
|
|
19
|
+
"release": "bumpp && npm publish",
|
|
20
|
+
"prepack": "bun run build"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"claude-code",
|
|
24
|
+
"ai-agents",
|
|
25
|
+
"session-manager",
|
|
26
|
+
"wave-terminal",
|
|
27
|
+
"herd",
|
|
28
|
+
"agentherder"
|
|
29
|
+
],
|
|
30
|
+
"author": "motin",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/generativereality/agentherder"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://agentherder.com",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20.19.4"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"registry": "https://registry.npmjs.org",
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@clack/prompts": "^0.9.1",
|
|
46
|
+
"consola": "^3.4.0",
|
|
47
|
+
"gunshi": "^0.23.0",
|
|
48
|
+
"update-notifier": "^7.3.1"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^22.0.0",
|
|
52
|
+
"@types/update-notifier": "^6.0.8",
|
|
53
|
+
"bumpp": "^9.11.1",
|
|
54
|
+
"tsdown": "^0.12.0",
|
|
55
|
+
"typescript": "^5.8.0"
|
|
56
|
+
}
|
|
57
|
+
}
|