shellmates 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/LICENSE +21 -0
- package/README.md +134 -0
- package/bin/shellmates.js +112 -0
- package/lib/commands/config.js +88 -0
- package/lib/commands/init.js +56 -0
- package/lib/commands/install-hook.js +83 -0
- package/lib/commands/spawn.js +98 -0
- package/lib/commands/status.js +69 -0
- package/lib/utils/config.js +35 -0
- package/lib/utils/logo.js +84 -0
- package/lib/utils/tmux.js +46 -0
- package/package.json +39 -0
- package/scripts/dispatch.sh +331 -0
- package/scripts/launch-full-team.sh +77 -0
- package/scripts/launch.sh +183 -0
- package/scripts/monitor.sh +113 -0
- package/scripts/spawn-team.sh +302 -0
- package/scripts/status.sh +168 -0
- package/scripts/teardown.sh +211 -0
- package/scripts/view-session.sh +98 -0
- package/scripts/watch-inbox.sh +71 -0
- package/templates/.codex/agents/default.toml +5 -0
- package/templates/.codex/agents/executor.toml +7 -0
- package/templates/.codex/agents/explorer.toml +5 -0
- package/templates/.codex/agents/planner.toml +6 -0
- package/templates/.codex/agents/researcher.toml +6 -0
- package/templates/.codex/agents/reviewer.toml +5 -0
- package/templates/.codex/agents/verifier.toml +6 -0
- package/templates/.codex/agents/worker.toml +5 -0
- package/templates/.codex/config.toml +43 -0
- package/templates/AGENTS.md +109 -0
- package/templates/CLAUDE.md +50 -0
- package/templates/GEMINI.md +136 -0
- package/templates/config.json +10 -0
- package/templates/gitignore-additions.txt +2 -0
- package/templates/hooks/settings-addition.json +20 -0
- package/templates/hooks/shellmates-notify.sh +77 -0
- package/templates/task-header.txt +10 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rohith Sathyanarayana
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# shellmates
|
|
2
|
+
|
|
3
|
+
Your terminal. Multiple AI models. All talking to each other.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
┌──────────────────────────┬──────────────────────────┬──────────────────────────┐
|
|
7
|
+
│ claude │ gemini │ codex │
|
|
8
|
+
│ │ │ │
|
|
9
|
+
│ Planning phase 3... │ Reading the plan... │ Reading the plan... │
|
|
10
|
+
│ Delegating to agents... │ Writing auth.py... │ Writing tests... │
|
|
11
|
+
│ Waiting for signals... │ Tests passing ✓ │ All passing ✓ │
|
|
12
|
+
│ Nice. On to phase 4. │ PHASE_COMPLETE: done │ PHASE_COMPLETE: done │
|
|
13
|
+
└──────────────────────────┴──────────────────────────┴──────────────────────────┘
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Claude plans. Gemini builds. Codex verifies. They coordinate through your terminal using nothing but tmux — no APIs between them, no glue code, just agents passing messages like coworkers at adjacent desks.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## What
|
|
21
|
+
|
|
22
|
+
shellmates is a lightweight orchestration layer for your terminal. It connects Claude Code, Gemini CLI, and Codex in side-by-side tmux panes — Claude holds the plan and delegates, the others execute in parallel and signal back when done.
|
|
23
|
+
|
|
24
|
+
No new framework. No shared memory. No API calls between models. Just text in a terminal.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Why
|
|
29
|
+
|
|
30
|
+
Every AI coding tool runs one model in one context. You hit a wall when the task gets big — context fills up, the model loses the thread, architectural decisions get buried in implementation noise.
|
|
31
|
+
|
|
32
|
+
shellmates splits the work the way a good team does:
|
|
33
|
+
|
|
34
|
+
- **One agent thinks.** Claude holds the plan, reviews the work, decides what's next. Uses [GSD](https://github.com/gsd-build/get-shit-done) to produce structured plans that sub-agents can execute without needing your entire conversation history.
|
|
35
|
+
- **Other agents build.** Gemini and Codex get a fresh context, a clear plan, and a specific job. They commit, signal done, and wait.
|
|
36
|
+
- **The terminal is the meeting room.** tmux `send-keys` delivers tasks. `capture-pane` reads the replies. That's the whole protocol.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## How it works
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
You describe what to build
|
|
44
|
+
↓
|
|
45
|
+
Claude plans it with /gsd:plan-phase → PLAN.md on disk
|
|
46
|
+
↓
|
|
47
|
+
Claude sends the plan to Gemini → tmux send-keys
|
|
48
|
+
Claude sends the plan to Codex → tmux send-keys (in parallel)
|
|
49
|
+
↓
|
|
50
|
+
Gemini implements, tests, commits
|
|
51
|
+
Codex implements, tests, commits (simultaneously)
|
|
52
|
+
↓
|
|
53
|
+
Both signal: PHASE_COMPLETE → Claude reads with capture-pane
|
|
54
|
+
↓
|
|
55
|
+
Claude reviews, decides next step
|
|
56
|
+
↓
|
|
57
|
+
repeat
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The plan lives on disk. Sub-agents read it fresh every time. No conversation history required, no context bleed between agents, no awkward handoffs.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+

|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Get started
|
|
69
|
+
|
|
70
|
+
**Point your AI agent at this:**
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
Read https://raw.githubusercontent.com/rs07-git/shellmates/main/INIT.md and set up shellmates for this project.
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
That's it. Your agent will install the tools, update your project files, fill in the config, and drop a personalized tutorial in your terminal. No manual steps required.
|
|
77
|
+
|
|
78
|
+
> Works with Claude Code, Gemini CLI, Codex, or any AI that can read a URL and run shell commands.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Want to understand what's happening first?
|
|
83
|
+
|
|
84
|
+
**→ [QUICKSTART.md](QUICKSTART.md)** — step-by-step walkthrough you can follow yourself
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Mix and match
|
|
89
|
+
|
|
90
|
+
| Orchestrator | Executor(s) | Good for |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| Claude | Gemini | Large context tasks, Google Search grounding |
|
|
93
|
+
| Claude | Codex | Sandboxed execution, internal multi-agent roles |
|
|
94
|
+
| Claude | Gemini + Codex | Parallel tracks — implement and verify simultaneously |
|
|
95
|
+
| Claude | Multiple Gemini panes | Fan-out across many files at once |
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## What's in the box
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
shellmates/
|
|
103
|
+
├── INIT.md ← agent-executable setup (point your AI here)
|
|
104
|
+
├── QUICKSTART.md ← human-readable setup guide
|
|
105
|
+
├── ORCHESTRATOR.md ← Claude's operating instructions (copied to your project)
|
|
106
|
+
├── templates/
|
|
107
|
+
│ ├── CLAUDE.md ← snippet added to your project's CLAUDE.md
|
|
108
|
+
│ ├── GEMINI.md ← filled in and added to your project root
|
|
109
|
+
│ ├── AGENTS.md ← for Codex
|
|
110
|
+
│ └── .codex/ ← Codex multi-agent role configs
|
|
111
|
+
├── scripts/
|
|
112
|
+
│ ├── launch.sh ← spin up a 2-pane session
|
|
113
|
+
│ ├── launch-full-team.sh ← spin up a 4-pane session
|
|
114
|
+
│ └── monitor.sh ← watch for signals in the background
|
|
115
|
+
└── docs/
|
|
116
|
+
├── WORKFLOW.md ← the plan/execute split explained
|
|
117
|
+
├── PROTOCOL.md ← full tmux IPC reference
|
|
118
|
+
├── ROLES.md ← patterns and when to use each
|
|
119
|
+
└── TROUBLESHOOTING.md
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## The protocol in one paragraph
|
|
125
|
+
|
|
126
|
+
Claude sends a task by running `tmux send-keys -t pane "do X" Enter`. The sub-agent does the work and prints `PHASE_COMPLETE: Phase N — summary` when done. Claude reads the output with `tmux capture-pane -t pane -p | tail -20`. No framework, no SDK, no shared state. Just text in a terminal.
|
|
127
|
+
|
|
128
|
+
Full spec in [docs/PROTOCOL.md](docs/PROTOCOL.md).
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander'
|
|
3
|
+
import { readFileSync } from 'fs'
|
|
4
|
+
import { join, dirname } from 'path'
|
|
5
|
+
import { fileURLToPath } from 'url'
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'))
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name('shellmates')
|
|
12
|
+
.description('Seamless tmux multi-agent orchestration')
|
|
13
|
+
.version(pkg.version)
|
|
14
|
+
|
|
15
|
+
// ── shellmates init ──────────────────────────────────────────────────────────
|
|
16
|
+
program
|
|
17
|
+
.command('init')
|
|
18
|
+
.description('First-time setup — create config and directories')
|
|
19
|
+
.option('--force', 'Reset config to defaults even if it already exists')
|
|
20
|
+
.action(async (opts) => {
|
|
21
|
+
const { init } = await import('../lib/commands/init.js')
|
|
22
|
+
await init(opts)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// ── shellmates config ────────────────────────────────────────────────────────
|
|
26
|
+
program
|
|
27
|
+
.command('config')
|
|
28
|
+
.description('Interactive settings — agent, permission mode, orchestrator')
|
|
29
|
+
.action(async () => {
|
|
30
|
+
const { config } = await import('../lib/commands/config.js')
|
|
31
|
+
await config()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// ── shellmates spawn ─────────────────────────────────────────────────────────
|
|
35
|
+
program
|
|
36
|
+
.command('spawn')
|
|
37
|
+
.description('Dispatch a task to a worker agent in a new tmux session')
|
|
38
|
+
.option('-t, --task <text>', 'Inline task text to dispatch')
|
|
39
|
+
.option('-f, --task-file <path>', 'Path to a file containing the task')
|
|
40
|
+
.option('-a, --agent <name>', 'Override agent for this task (gemini|codex)')
|
|
41
|
+
.option('-s, --session <name>', 'tmux session name (default: shellmates-<ts>)')
|
|
42
|
+
.option('-p, --project <path>', 'Project directory for the worker (default: cwd)')
|
|
43
|
+
.option('-w, --watch', 'Wait and print result when the agent finishes')
|
|
44
|
+
.option('--no-ping', 'Skip background inbox watcher')
|
|
45
|
+
.action(async (opts) => {
|
|
46
|
+
const { spawn } = await import('../lib/commands/spawn.js')
|
|
47
|
+
await spawn({
|
|
48
|
+
task: opts.task,
|
|
49
|
+
taskFile: opts.taskFile,
|
|
50
|
+
agent: opts.agent,
|
|
51
|
+
session: opts.session,
|
|
52
|
+
project: opts.project,
|
|
53
|
+
watch: opts.watch,
|
|
54
|
+
noPing: !opts.ping,
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// ── shellmates status ────────────────────────────────────────────────────────
|
|
59
|
+
program
|
|
60
|
+
.command('status')
|
|
61
|
+
.description('Show active sessions, config, and inbox results')
|
|
62
|
+
.action(async () => {
|
|
63
|
+
const { status } = await import('../lib/commands/status.js')
|
|
64
|
+
await status()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// ── shellmates install-hook ──────────────────────────────────────────────────
|
|
68
|
+
program
|
|
69
|
+
.command('install-hook')
|
|
70
|
+
.description('Install Claude Code PostToolUse hook for native AGENT_PING notifications')
|
|
71
|
+
.option('--force', 'Overwrite existing hook and re-add settings entry')
|
|
72
|
+
.action(async (opts) => {
|
|
73
|
+
const { installHook } = await import('../lib/commands/install-hook.js')
|
|
74
|
+
await installHook(opts)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// ── shellmates teardown ──────────────────────────────────────────────────────
|
|
78
|
+
program
|
|
79
|
+
.command('teardown [session]')
|
|
80
|
+
.description('Kill shellmates tmux sessions and clean up (default: all)')
|
|
81
|
+
.action(async (session) => {
|
|
82
|
+
const { execSync } = await import('child_process')
|
|
83
|
+
const chalk = (await import('chalk')).default
|
|
84
|
+
let sessions = []
|
|
85
|
+
try {
|
|
86
|
+
const out = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
87
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']
|
|
88
|
+
})
|
|
89
|
+
sessions = out.trim().split('\n').filter(Boolean)
|
|
90
|
+
} catch {}
|
|
91
|
+
|
|
92
|
+
const targets = session
|
|
93
|
+
? sessions.filter(s => s === session)
|
|
94
|
+
: sessions.filter(s => s.startsWith('shellmates'))
|
|
95
|
+
|
|
96
|
+
if (targets.length === 0) {
|
|
97
|
+
console.log(chalk.dim('\n No shellmates sessions to tear down.\n'))
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const s of targets) {
|
|
102
|
+
try {
|
|
103
|
+
execSync(`tmux kill-session -t ${s}`, { stdio: 'ignore' })
|
|
104
|
+
console.log(chalk.green(' ✓') + ` Killed session: ${s}`)
|
|
105
|
+
} catch {
|
|
106
|
+
console.log(chalk.red(' ✗') + ` Could not kill: ${s}`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
console.log('')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
program.parse()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import inquirer from 'inquirer'
|
|
3
|
+
import { readConfig, writeConfig, ensureDirs } from '../utils/config.js'
|
|
4
|
+
|
|
5
|
+
const AGENT_CHOICES = [
|
|
6
|
+
{ name: 'Gemini CLI (google/gemini-cli)', value: 'gemini' },
|
|
7
|
+
{ name: 'Codex CLI (openai/codex)', value: 'codex' },
|
|
8
|
+
{ name: 'Ask me each time', value: 'ask' },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
const ORCHESTRATOR_CHOICES = [
|
|
12
|
+
{ name: 'Claude Code (this session)', value: 'claude' },
|
|
13
|
+
{ name: 'Gemini CLI', value: 'gemini' },
|
|
14
|
+
{ name: 'Codex CLI', value: 'codex' },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export async function config() {
|
|
18
|
+
ensureDirs()
|
|
19
|
+
const current = readConfig()
|
|
20
|
+
|
|
21
|
+
console.log('')
|
|
22
|
+
console.log(chalk.bold(' Shellmates — Settings'))
|
|
23
|
+
console.log(chalk.dim(' ────────────────────────────────────'))
|
|
24
|
+
console.log(chalk.dim(` Current config: ~/.shellmates/config.json`))
|
|
25
|
+
console.log('')
|
|
26
|
+
|
|
27
|
+
const answers = await inquirer.prompt([
|
|
28
|
+
{
|
|
29
|
+
type: 'list',
|
|
30
|
+
name: 'default_agent',
|
|
31
|
+
message: 'Default worker agent:',
|
|
32
|
+
choices: AGENT_CHOICES,
|
|
33
|
+
default: current.default_agent,
|
|
34
|
+
prefix: ' ',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
type: 'list',
|
|
38
|
+
name: 'orchestrator',
|
|
39
|
+
message: 'Orchestrator (who dispatches tasks):',
|
|
40
|
+
choices: ORCHESTRATOR_CHOICES,
|
|
41
|
+
default: current.orchestrator,
|
|
42
|
+
prefix: ' ',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: 'list',
|
|
46
|
+
name: 'permission_mode',
|
|
47
|
+
message: 'Permission mode:',
|
|
48
|
+
choices: [
|
|
49
|
+
{
|
|
50
|
+
name: 'default — agents ask before modifying files or running commands',
|
|
51
|
+
value: 'default',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'bypass — agents run fully autonomously (gemini --yolo, codex --full-auto)',
|
|
55
|
+
value: 'bypass',
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
default: current.permission_mode,
|
|
59
|
+
prefix: ' ',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: 'confirm',
|
|
63
|
+
name: 'bypass_confirmed',
|
|
64
|
+
message: chalk.yellow('Bypass mode lets agents modify files without asking. Are you sure?'),
|
|
65
|
+
default: false,
|
|
66
|
+
prefix: ' ',
|
|
67
|
+
when: (ans) => ans.permission_mode === 'bypass' && current.permission_mode !== 'bypass',
|
|
68
|
+
},
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
// If user chose bypass but didn't confirm, revert to default
|
|
72
|
+
if (answers.permission_mode === 'bypass' && answers.bypass_confirmed === false) {
|
|
73
|
+
answers.permission_mode = 'default'
|
|
74
|
+
console.log('')
|
|
75
|
+
console.log(chalk.dim(' Permission mode kept as: default'))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { bypass_confirmed, ...toSave } = answers
|
|
79
|
+
writeConfig(toSave)
|
|
80
|
+
|
|
81
|
+
console.log('')
|
|
82
|
+
console.log(chalk.green(' ✓') + ' Settings saved.')
|
|
83
|
+
console.log('')
|
|
84
|
+
console.log(chalk.dim(' permission_mode: ') + chalk.bold(toSave.permission_mode))
|
|
85
|
+
console.log(chalk.dim(' default_agent: ') + chalk.bold(toSave.default_agent))
|
|
86
|
+
console.log(chalk.dim(' orchestrator: ') + chalk.bold(toSave.orchestrator))
|
|
87
|
+
console.log('')
|
|
88
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { existsSync } from 'fs'
|
|
3
|
+
import { CONFIG_PATH, CONFIG_DIR, INBOX_DIR, readConfig, writeConfig, ensureDirs } from '../utils/config.js'
|
|
4
|
+
import { tmuxAvailable } from '../utils/tmux.js'
|
|
5
|
+
import { printLogo } from '../utils/logo.js'
|
|
6
|
+
|
|
7
|
+
export async function init({ force = false } = {}) {
|
|
8
|
+
const { readFileSync } = await import('fs')
|
|
9
|
+
const { join, dirname, fileURLToPath } = await import('path')
|
|
10
|
+
const { fileURLToPath: fu } = await import('url')
|
|
11
|
+
let version = '0.1.0'
|
|
12
|
+
try {
|
|
13
|
+
const __dirname = dirname(fu(import.meta.url))
|
|
14
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'))
|
|
15
|
+
version = pkg.version
|
|
16
|
+
} catch {}
|
|
17
|
+
|
|
18
|
+
printLogo(version)
|
|
19
|
+
console.log(chalk.dim(' ─────────────────────────────'))
|
|
20
|
+
console.log('')
|
|
21
|
+
|
|
22
|
+
// tmux check
|
|
23
|
+
if (!tmuxAvailable()) {
|
|
24
|
+
console.log(chalk.red(' ✗ tmux not found'))
|
|
25
|
+
console.log(chalk.dim(' Install it: brew install tmux'))
|
|
26
|
+
console.log('')
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
29
|
+
console.log(chalk.green(' ✓') + ' tmux found')
|
|
30
|
+
|
|
31
|
+
// Create dirs
|
|
32
|
+
ensureDirs()
|
|
33
|
+
console.log(chalk.green(' ✓') + ` ~/.shellmates/ ready`)
|
|
34
|
+
console.log(chalk.green(' ✓') + ` ~/.shellmates/inbox/ ready`)
|
|
35
|
+
|
|
36
|
+
// Config
|
|
37
|
+
if (existsSync(CONFIG_PATH) && !force) {
|
|
38
|
+
console.log(chalk.yellow(' ~') + ` Config already exists at ${CONFIG_PATH}`)
|
|
39
|
+
console.log(chalk.dim(' Run with --force to reset, or use: shellmates config'))
|
|
40
|
+
} else {
|
|
41
|
+
writeConfig({
|
|
42
|
+
permission_mode: 'default',
|
|
43
|
+
default_agent: 'gemini',
|
|
44
|
+
orchestrator: 'claude',
|
|
45
|
+
})
|
|
46
|
+
console.log(chalk.green(' ✓') + ` Config created at ${CONFIG_PATH}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log('')
|
|
50
|
+
console.log(chalk.bold(' Ready.'))
|
|
51
|
+
console.log('')
|
|
52
|
+
console.log(' Next steps:')
|
|
53
|
+
console.log(chalk.dim(' shellmates config') + ' — configure agents and permission mode')
|
|
54
|
+
console.log(chalk.dim(' shellmates spawn') + ' — dispatch a task to a worker agent')
|
|
55
|
+
console.log('')
|
|
56
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, chmodSync } from 'fs'
|
|
3
|
+
import { join, dirname, fileURLToPath } from 'path'
|
|
4
|
+
import { homedir } from 'os'
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const TEMPLATES_DIR = join(__dirname, '..', '..', 'templates', 'hooks')
|
|
8
|
+
|
|
9
|
+
const CLAUDE_DIR = join(homedir(), '.claude')
|
|
10
|
+
const HOOKS_DIR = join(CLAUDE_DIR, 'hooks')
|
|
11
|
+
const SETTINGS_PATH = join(CLAUDE_DIR, 'settings.json')
|
|
12
|
+
const HOOK_SCRIPT = join(HOOKS_DIR, 'shellmates-notify.sh')
|
|
13
|
+
|
|
14
|
+
const HOOK_ENTRY = {
|
|
15
|
+
matcher: 'Bash',
|
|
16
|
+
hooks: [
|
|
17
|
+
{
|
|
18
|
+
type: 'command',
|
|
19
|
+
command: '~/.claude/hooks/shellmates-notify.sh',
|
|
20
|
+
async: true,
|
|
21
|
+
asyncRewake: true,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function installHook({ force = false } = {}) {
|
|
27
|
+
console.log('')
|
|
28
|
+
console.log(chalk.bold(' Shellmates — Install Claude Code Hook'))
|
|
29
|
+
console.log(chalk.dim(' ─────────────────────────────────────────'))
|
|
30
|
+
console.log('')
|
|
31
|
+
console.log(chalk.dim(' This installs a PostToolUse hook that notifies Claude natively'))
|
|
32
|
+
console.log(chalk.dim(' when a shellmates agent finishes — no polling needed.'))
|
|
33
|
+
console.log('')
|
|
34
|
+
|
|
35
|
+
// 1. Copy hook script
|
|
36
|
+
mkdirSync(HOOKS_DIR, { recursive: true })
|
|
37
|
+
if (existsSync(HOOK_SCRIPT) && !force) {
|
|
38
|
+
console.log(chalk.yellow(' ~') + ` Hook script already exists (use --force to overwrite)`)
|
|
39
|
+
} else {
|
|
40
|
+
copyFileSync(join(TEMPLATES_DIR, 'shellmates-notify.sh'), HOOK_SCRIPT)
|
|
41
|
+
chmodSync(HOOK_SCRIPT, 0o755)
|
|
42
|
+
console.log(chalk.green(' ✓') + ` Hook script installed: ~/.claude/hooks/shellmates-notify.sh`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 2. Merge hook entry into settings.json
|
|
46
|
+
let settings = {}
|
|
47
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
48
|
+
try {
|
|
49
|
+
settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
|
|
50
|
+
} catch {
|
|
51
|
+
console.log(chalk.yellow(' ~') + ` Could not parse ${SETTINGS_PATH} — will create fresh`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check if our hook is already there
|
|
56
|
+
const postToolUse = settings?.hooks?.PostToolUse || []
|
|
57
|
+
const alreadyInstalled = postToolUse.some(
|
|
58
|
+
entry => entry.hooks?.some(h => h.command?.includes('shellmates-notify'))
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if (alreadyInstalled && !force) {
|
|
62
|
+
console.log(chalk.yellow(' ~') + ` Hook entry already in settings.json (use --force to re-add)`)
|
|
63
|
+
} else {
|
|
64
|
+
if (!settings.hooks) settings.hooks = {}
|
|
65
|
+
if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = []
|
|
66
|
+
// Remove old shellmates entry if force
|
|
67
|
+
if (force) {
|
|
68
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
|
|
69
|
+
e => !e.hooks?.some(h => h.command?.includes('shellmates-notify'))
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
settings.hooks.PostToolUse.push(HOOK_ENTRY)
|
|
73
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n')
|
|
74
|
+
console.log(chalk.green(' ✓') + ` Hook entry added to ~/.claude/settings.json`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log('')
|
|
78
|
+
console.log(chalk.bold(' Done.'))
|
|
79
|
+
console.log('')
|
|
80
|
+
console.log(' After your next ' + chalk.dim('shellmates spawn') + ', Claude will be notified')
|
|
81
|
+
console.log(' automatically when the agent finishes — no polling.')
|
|
82
|
+
console.log('')
|
|
83
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { spawnSync } from 'child_process'
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
|
|
4
|
+
import { join, dirname } from 'path'
|
|
5
|
+
import { fileURLToPath } from 'url'
|
|
6
|
+
import { tmpdir, homedir } from 'os'
|
|
7
|
+
import { readConfig } from '../utils/config.js'
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const SCRIPTS_DIR = join(__dirname, '..', '..', 'scripts')
|
|
11
|
+
const INBOX_DIR = join(homedir(), '.shellmates', 'inbox')
|
|
12
|
+
|
|
13
|
+
export async function spawn(options) {
|
|
14
|
+
const config = readConfig()
|
|
15
|
+
|
|
16
|
+
const agent = options.agent || config.default_agent
|
|
17
|
+
const session = options.session || `shellmates-${Date.now()}`
|
|
18
|
+
const project = options.project || process.cwd()
|
|
19
|
+
const noPing = options.noPing || false
|
|
20
|
+
|
|
21
|
+
// Resolve task content
|
|
22
|
+
let taskFile = options.taskFile
|
|
23
|
+
if (!taskFile && options.task) {
|
|
24
|
+
// Inline task string → write to temp file
|
|
25
|
+
const tmp = join(tmpdir(), `shellmates-task-${Date.now()}.txt`)
|
|
26
|
+
writeFileSync(tmp, options.task)
|
|
27
|
+
taskFile = tmp
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!taskFile || !existsSync(taskFile)) {
|
|
31
|
+
console.error(chalk.red(' ✗ No task provided. Use --task "..." or --task-file path'))
|
|
32
|
+
process.exit(1)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log('')
|
|
36
|
+
console.log(chalk.bold(' Shellmates — Spawning'))
|
|
37
|
+
console.log(chalk.dim(' ─────────────────────────────────────'))
|
|
38
|
+
console.log(chalk.dim(' Agent: ') + chalk.bold(agent))
|
|
39
|
+
console.log(chalk.dim(' Session: ') + chalk.bold(session))
|
|
40
|
+
console.log(chalk.dim(' Mode: ') + chalk.bold(config.permission_mode))
|
|
41
|
+
console.log('')
|
|
42
|
+
|
|
43
|
+
const spawnScript = join(SCRIPTS_DIR, 'spawn-team.sh')
|
|
44
|
+
const args = [
|
|
45
|
+
'--task-file', taskFile,
|
|
46
|
+
'--project', project,
|
|
47
|
+
'--session', session,
|
|
48
|
+
'--agent', agent,
|
|
49
|
+
]
|
|
50
|
+
if (noPing) args.push('--no-ping')
|
|
51
|
+
|
|
52
|
+
const result = spawnSync('bash', [spawnScript, ...args], { stdio: 'inherit' })
|
|
53
|
+
|
|
54
|
+
if (result.status !== 0) {
|
|
55
|
+
console.error(chalk.red('\n ✗ Spawn failed'))
|
|
56
|
+
process.exit(result.status || 1)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// If --watch, tail the inbox until the job file appears
|
|
60
|
+
if (options.watch) {
|
|
61
|
+
await watchInbox(session)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function watchInbox(session) {
|
|
66
|
+
const { readdir } = await import('fs/promises')
|
|
67
|
+
console.log('')
|
|
68
|
+
console.log(chalk.dim(' Watching for result... (Ctrl+C to stop)'))
|
|
69
|
+
|
|
70
|
+
const before = new Set(existsSync(INBOX_DIR)
|
|
71
|
+
? (await readdir(INBOX_DIR)).filter(f => f.endsWith('.txt'))
|
|
72
|
+
: [])
|
|
73
|
+
|
|
74
|
+
let elapsed = 0
|
|
75
|
+
const timeout = 300
|
|
76
|
+
while (elapsed < timeout) {
|
|
77
|
+
await new Promise(r => setTimeout(r, 2000))
|
|
78
|
+
elapsed += 2
|
|
79
|
+
if (!existsSync(INBOX_DIR)) continue
|
|
80
|
+
const after = (await readdir(INBOX_DIR)).filter(f => f.endsWith('.txt'))
|
|
81
|
+
const newFiles = after.filter(f => !before.has(f))
|
|
82
|
+
if (newFiles.length > 0) {
|
|
83
|
+
for (const f of newFiles) {
|
|
84
|
+
const content = readFileSync(join(INBOX_DIR, f), 'utf8')
|
|
85
|
+
console.log('')
|
|
86
|
+
console.log(chalk.green(' ✓ Result received') + chalk.dim(` (${f})`))
|
|
87
|
+
console.log('')
|
|
88
|
+
for (const line of content.trim().split('\n')) {
|
|
89
|
+
console.log(' ' + chalk.dim(line.split(':')[0] + ':') + ' ' + line.split(':').slice(1).join(':').trim())
|
|
90
|
+
}
|
|
91
|
+
console.log('')
|
|
92
|
+
}
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
console.log(chalk.yellow('\n ~ Timed out waiting for result'))
|
|
98
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { readdirSync, readFileSync, existsSync } from 'fs'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { homedir } from 'os'
|
|
5
|
+
import { execSync } from 'child_process'
|
|
6
|
+
import { readConfig, INBOX_DIR } from '../utils/config.js'
|
|
7
|
+
|
|
8
|
+
export async function status() {
|
|
9
|
+
const config = readConfig()
|
|
10
|
+
|
|
11
|
+
console.log('')
|
|
12
|
+
console.log(chalk.bold(' Shellmates — Status'))
|
|
13
|
+
console.log(chalk.dim(' ────────────────────────────────────'))
|
|
14
|
+
console.log('')
|
|
15
|
+
|
|
16
|
+
// Config summary
|
|
17
|
+
console.log(chalk.dim(' Config'))
|
|
18
|
+
console.log(' ' + chalk.dim('permission_mode: ') + chalk.bold(config.permission_mode))
|
|
19
|
+
console.log(' ' + chalk.dim('default_agent: ') + chalk.bold(config.default_agent))
|
|
20
|
+
console.log(' ' + chalk.dim('orchestrator: ') + chalk.bold(config.orchestrator))
|
|
21
|
+
console.log('')
|
|
22
|
+
|
|
23
|
+
// Active tmux sessions
|
|
24
|
+
let sessions = []
|
|
25
|
+
try {
|
|
26
|
+
const out = execSync('tmux list-sessions -F "#{session_name}"', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
27
|
+
sessions = out.trim().split('\n').filter(Boolean)
|
|
28
|
+
} catch {
|
|
29
|
+
// tmux not running or no sessions
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const shellmateSessions = sessions.filter(s => s.startsWith('shellmates'))
|
|
33
|
+
console.log(chalk.dim(' Active sessions'))
|
|
34
|
+
if (shellmateSessions.length === 0) {
|
|
35
|
+
console.log(' ' + chalk.dim('none'))
|
|
36
|
+
} else {
|
|
37
|
+
for (const s of shellmateSessions) {
|
|
38
|
+
console.log(' ' + chalk.green('●') + ' ' + s)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
console.log('')
|
|
42
|
+
|
|
43
|
+
// Inbox files
|
|
44
|
+
console.log(chalk.dim(' Inbox'))
|
|
45
|
+
if (!existsSync(INBOX_DIR)) {
|
|
46
|
+
console.log(' ' + chalk.dim('empty'))
|
|
47
|
+
} else {
|
|
48
|
+
const files = readdirSync(INBOX_DIR).filter(f => f.endsWith('.txt'))
|
|
49
|
+
if (files.length === 0) {
|
|
50
|
+
console.log(' ' + chalk.dim('empty'))
|
|
51
|
+
} else {
|
|
52
|
+
for (const f of files) {
|
|
53
|
+
try {
|
|
54
|
+
const content = readFileSync(join(INBOX_DIR, f), 'utf8')
|
|
55
|
+
const statusLine = content.split('\n').find(l => l.startsWith('STATUS:'))
|
|
56
|
+
const resultLine = content.split('\n').find(l => l.startsWith('RESULT:'))
|
|
57
|
+
const statusVal = statusLine?.split(':')[1]?.trim() || '?'
|
|
58
|
+
const resultVal = resultLine?.split(':').slice(1).join(':').trim() || ''
|
|
59
|
+
const icon = statusVal === 'complete' ? chalk.green('✓') : chalk.yellow('~')
|
|
60
|
+
console.log(' ' + icon + ' ' + chalk.dim(f))
|
|
61
|
+
if (resultVal) console.log(' ' + chalk.dim(resultVal.split('\n')[0]))
|
|
62
|
+
} catch {
|
|
63
|
+
console.log(' ' + chalk.dim(f))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
console.log('')
|
|
69
|
+
}
|