tmux-team 3.0.1 β†’ 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,310 +1,106 @@
1
- # πŸ€– tmux-team
1
+ # tmux-team
2
2
 
3
- **The lightweight coordination layer for terminal-based AI agents.**
3
+ Coordinate AI agents (Claude, Codex, Gemini) running in tmux panes. Send messages, wait for responses, broadcast to all.
4
4
 
5
- tmux-team is a protocol-agnostic transport layer that enables multi-agent collaboration directly within your existing tmux workflow. It turns a collection of isolated terminal panes into a coordinated AI team.
6
-
7
- ---
8
-
9
- ## πŸ›‘ The Problem
10
-
11
- As we move from "Chat with an AI" to "Orchestrating a Team," we face major friction points:
12
-
13
- 1. **Isolation** β€” Agents in different panes (Claude, Gemini, local LLMs) have no way to talk to each other
14
- 2. **Synchronization** β€” Humans are stuck in a "manual polling" loopβ€”waiting for an agent to finish before copying its output to the next pane
15
- 3. **Tool Restrictions** β€” AI agents operate under tool whitelists; using `sleep` or arbitrary shell commands is dangerous or blocked
16
- 4. **Token Waste** β€” Repeated polling instructions burn context tokens unnecessarily
17
-
18
- ---
19
-
20
- ## πŸš€ Our Niche: The Universal Transport Layer
21
-
22
- Unlike heavyweight frameworks that require specific SDKs or cloud infrastructure, tmux-team treats the **terminal pane as the universal interface**.
23
-
24
- - **Model Agnostic** β€” Works with Claude Code, Gemini CLI, Codex, Aider, or any CLI tool
25
- - **Zero Infrastructure** β€” No servers, no MCP setup, no complex configuration. If it runs in tmux, tmux-team can talk to it
26
- - **Whitelist-Friendly** β€” A single `tmux-team talk:*` prefix covers all operations, keeping AI tool permissions simple and safe
27
- - **Local-First** β€” Per-project `tmux-team.json` lives with your repo; global config in `~/.config/tmux-team/`
28
-
29
- ---
30
-
31
- ## 🧠 Design Philosophy
32
-
33
- > *These principles guide our design decisions.*
34
-
35
- ### 1. Deterministic Transport (`--delay` vs. `sleep`)
36
-
37
- **The Problem**: Tool allowlists typically approve one safe command (`tmux-team talk ...`) but not arbitrary shell commands. Using `sleep` is often blocked by security policies.
38
-
39
- **The Why**: Internal delay keeps the workflow as a single tool call. No shell dependency, no policy friction.
40
-
41
- ### 2. Stateless Handshakes (The "Nonce" Strategy)
42
-
43
- **The Problem**: Terminal panes are streams, not RPC channels. A simple `[DONE]` string could already be in scrollback.
44
-
45
- **The Why**: We use a unique **Nonce** for every request: `{tmux-team-end:8f3a}`.
46
- - **Collision Avoidance** β€” Prevents matching markers from previous turns
47
- - **Completion Safety** β€” Ensures the agent has truly finished
48
- - **Zero-API RPC** β€” Creates request/response semantics over a standard TTY
49
-
50
- ### 3. Context Injection (Preambles)
51
-
52
- **The Problem**: AI agents are prone to "instruction drift." Over a long session, they might forget constraints.
53
-
54
- **The Why**: Preambles act as a forced system prompt for CLI environments. By injecting these "hidden instructions" at the transport level, we ensure the agent remains in character.
55
-
56
- ---
57
-
58
- ## πŸ“¦ Installation
5
+ ## Install
59
6
 
60
7
  ```bash
61
8
  npm install -g tmux-team
62
9
  ```
63
10
 
64
- **Requirements:** Node.js >= 18, tmux, macOS/Linux
65
-
66
- **Alias:** `tmt` is available as a shorthand for `tmux-team`
67
-
68
- ### Shell Completion
69
-
70
- ```bash
71
- # Zsh (add to ~/.zshrc)
72
- eval "$(tmux-team completion zsh)"
73
-
74
- # Bash (add to ~/.bashrc)
75
- eval "$(tmux-team completion bash)"
76
- ```
77
-
78
- ### Claude Code Plugin
11
+ **Requirements:** Node.js >= 18, tmux
79
12
 
80
- ```
81
- /plugin marketplace add wkh237/tmux-team
82
- /plugin install tmux-team@tmux-team
83
- ```
13
+ **Alias:** `tmt` (shorthand for `tmux-team`)
84
14
 
85
- ### Agent Skills (Optional)
86
-
87
- Install tmux-team as a native skill for your AI coding agent:
15
+ ## Quick Start
88
16
 
89
17
  ```bash
90
- # Install for Claude Code (user-wide)
91
- tmux-team install-skill claude
92
-
93
- # Install for OpenAI Codex (user-wide)
94
- tmux-team install-skill codex
18
+ # 1. Install for your AI agent
19
+ tmux-team install claude # or: tmux-team install codex
95
20
 
96
- # Install to project directory instead
97
- tmux-team install-skill claude --local
98
- tmux-team install-skill codex --local
99
- ```
21
+ # 2. Run the setup wizard (auto-detects panes)
22
+ tmux-team setup
100
23
 
101
- See [skills/README.md](./skills/README.md) for detailed instructions.
102
-
103
- ---
104
-
105
- ## ⌨️ Quick Start
106
-
107
- ```bash
108
- # Initialize config
109
- tmux-team init
110
-
111
- # Register your agents (name + tmux pane ID)
112
- tmux-team add claude 10.0 "Frontend specialist"
113
- tmux-team add codex 10.1 "Backend engineer"
114
- tmux-team add gemini 10.2 "Code reviewer"
115
-
116
- # Send messages and wait for response (recommended for better token utilization)
117
- tmux-team talk codex "Review the auth module" --wait
118
- tmux-team talk all "Starting the refactor now" --wait
119
-
120
- # Or use the shorthand alias
121
- tmt talk codex "Quick question" --wait
122
-
123
- # Manage agents
124
- tmux-team list
125
- tmux-team update codex --remark "Now handling tests"
126
- tmux-team remove gemini
127
-
128
- # Learn more
129
- tmux-team learn
130
- ```
131
-
132
- ### From Claude Code
133
-
134
- Once the plugin is installed, coordinate directly from your Claude Code session:
135
-
136
- ```
137
- /tmux-team:team codex "Can you review my changes?" --wait
138
- /tmux-team:team all "I'm starting the database migration" --wait
24
+ # 3. Talk to agents
25
+ tmux-team talk codex "Review this code" --wait
139
26
  ```
140
27
 
141
- ---
28
+ The `--wait` flag blocks until the agent responds, returning the response directly.
142
29
 
143
- ## πŸ“‹ Commands
30
+ ## Commands
144
31
 
145
32
  | Command | Description |
146
33
  |---------|-------------|
147
- | `talk <agent> "<msg>" --wait` | Send message and wait for response (recommended) |
148
- | `talk ... --delay 5` | Wait 5 seconds before sending |
149
- | `talk ... --timeout 300` | Set max wait time (default: 180s) |
150
- | `check <agent> [lines]` | Read agent's terminal output (default: 100 lines) |
151
- | `list` | Show all configured agents |
152
- | `add <name> <pane> [remark]` | Register a new agent |
153
- | `update <name> --pane/--remark` | Update agent configuration |
154
- | `remove <name>` | Unregister an agent |
155
- | `init` | Create `tmux-team.json` in current directory |
156
- | `config [show/set/clear]` | View/modify settings |
157
- | `preamble [show/set/clear]` | Manage agent preambles |
158
- | `install-skill <agent>` | Install skill for Claude/Codex (--local/--user) |
34
+ | `install [claude\|codex]` | Install tmux-team for an AI agent |
35
+ | `setup` | Interactive wizard to configure agents |
36
+ | `talk <agent> "msg" --wait` | Send message and wait for response |
37
+ | `talk all "msg" --wait` | Broadcast to all agents |
38
+ | `check <agent> [lines]` | Read agent's pane output (fallback if --wait times out) |
39
+ | `list` | Show configured agents |
159
40
  | `learn` | Show educational guide |
160
- | `completion [zsh\|bash]` | Output shell completion script |
161
41
 
162
- ---
42
+ **Options for `talk --wait`:**
43
+ - `--timeout <seconds>` - Max wait time (default: 180s)
44
+ - `--lines <number>` - Lines to capture from response (default: 100)
163
45
 
164
- ## βš™οΈ Configuration
46
+ Run `tmux-team help` for all commands and options.
165
47
 
166
- ### Local Config (`./tmux-team.json`)
48
+ ## Managing Your Team
167
49
 
168
- Per-project agent registry with optional preambles:
50
+ Configuration lives in `tmux-team.json` in your project root.
169
51
 
170
- ```json
171
- {
172
- "claude": {
173
- "pane": "10.0",
174
- "remark": "Frontend specialist",
175
- "preamble": "Focus on UI components. Ask for review before merging."
176
- },
177
- "codex": {
178
- "pane": "10.1",
179
- "remark": "Code reviewer",
180
- "preamble": "You are the code quality guard. Review changes thoroughly."
181
- }
182
- }
52
+ **Create** - Run the setup wizard to auto-detect agents:
53
+ ```bash
54
+ tmux-team setup
183
55
  ```
184
56
 
185
- | Field | Description |
186
- |-------|-------------|
187
- | `pane` | tmux pane ID (required) |
188
- | `remark` | Description shown in `list` |
189
- | `preamble` | Hidden instructions prepended to every message |
190
-
191
- ### Global Config (`~/.config/tmux-team/config.json`)
192
-
193
- Global settings that apply to all projects:
57
+ **Read** - List configured agents:
58
+ ```bash
59
+ tmux-team list
60
+ ```
194
61
 
62
+ **Update** - Edit `tmux-team.json` directly or re-run setup:
195
63
  ```json
196
64
  {
197
- "mode": "polling",
198
- "preambleMode": "always",
199
- "defaults": {
200
- "timeout": 180,
201
- "pollInterval": 1,
202
- "captureLines": 100,
203
- "preambleEvery": 3
204
- }
65
+ "codex": { "pane": "%1", "remark": "Code reviewer" },
66
+ "gemini": { "pane": "%2", "remark": "Documentation" }
205
67
  }
206
68
  ```
207
69
 
208
- | Field | Description |
209
- |-------|-------------|
210
- | `mode` | Default mode: `polling` (manual check) or `wait` (auto-wait) |
211
- | `preambleMode` | `always` (inject preambles) or `disabled` |
212
- | `defaults.timeout` | Default --wait timeout in seconds |
213
- | `defaults.pollInterval` | Polling interval in seconds |
214
- | `defaults.captureLines` | Default lines for `check` command |
215
- | `defaults.preambleEvery` | Inject preamble every N messages (default: 3) |
216
-
217
- ---
70
+ **Delete** - Remove an agent entry from `tmux-team.json` or delete the file entirely.
218
71
 
219
- ## ✨ Features
72
+ Find pane IDs: `tmux display-message -p "#{pane_id}"`
220
73
 
221
- ### πŸ“‘ Async Mode (Recommended)
74
+ ## Claude Code Plugin
222
75
 
223
- The `--wait` flag is recommended for better token utilization:
224
-
225
- ```bash
226
- # Wait for response with nonce-based completion detection
227
- tmux-team talk codex "Review this code" --wait
228
-
229
- # With custom timeout for complex tasks
230
- tmux-team talk codex "Implement the feature" --wait --timeout 300
231
-
232
- # Delay before sending (safe alternative to sleep)
233
- tmux-team talk codex "message" --wait --delay 5
234
76
  ```
235
-
236
- Enable by default: `tmux-team config set mode wait`
237
-
238
- ### πŸ“œ Agent Preambles
239
-
240
- Inject hidden instructions into every message via local `tmux-team.json`:
241
-
242
- ```json
243
- {
244
- "gemini": {
245
- "pane": "10.2",
246
- "preamble": "Always explain your reasoning. Do not edit files directly."
247
- }
248
- }
77
+ /plugin marketplace add wkh237/tmux-team
78
+ /plugin install tmux-team
249
79
  ```
250
80
 
251
- Use the CLI to manage preambles:
81
+ Gives you two slash commands:
252
82
 
253
- ```bash
254
- tmux-team preamble show gemini # View current preamble
255
- tmux-team preamble set gemini "Be concise" # Set preamble
256
- tmux-team preamble clear gemini # Remove preamble
83
+ **`/learn`** - Teach Claude how to use tmux-team
257
84
  ```
85
+ /learn
86
+ ```
87
+ Run this once when starting a session. Claude will understand how to coordinate with other agents.
258
88
 
259
- ---
260
-
261
- ## 🚫 Non-Goals
262
-
263
- tmux-team intentionally stays lightweight:
264
-
265
- - **Not an orchestrator** β€” No automatic agent selection or routing
266
- - **Not a session manager** β€” Doesn't create/manage tmux sessions
267
- - **Not an LLM wrapper** β€” Doesn't process or route messages through AI
268
-
269
- It's the plumbing layer that lets humans and AI agents coordinate via tmux, nothing more.
270
-
271
- ---
272
-
273
- ## πŸ“– Command Reference
274
-
275
- ### talk Options
276
-
277
- | Option | Description |
278
- |--------|-------------|
279
- | `--delay <seconds>` | Wait before sending (whitelist-friendly alternative to `sleep`) |
280
- | `--wait` | Block until agent responds (nonce-based completion detection) |
281
- | `--timeout <seconds>` | Max wait time (default: 180s) |
282
- | `--no-preamble` | Skip agent preamble for this message |
283
-
284
- ### config Command
285
-
286
- ```bash
287
- tmux-team config show # Show current config
288
- tmux-team config set mode wait # Enable wait mode
289
- tmux-team config set preambleMode disabled # Disable preambles
290
- tmux-team config set preambleEvery 5 # Inject preamble every 5 messages
291
- tmux-team config clear <key> # Clear a config value
89
+ **`/team`** - Talk to other agents
90
+ ```
91
+ /team talk codex "Review my authentication changes" --wait
92
+ /team talk all "I'm starting the database migration" --wait
93
+ /team list
292
94
  ```
95
+ Use this to delegate tasks, ask for reviews, or broadcast updates.
293
96
 
294
- ### preamble Command
97
+ ## Learn More
295
98
 
296
99
  ```bash
297
- tmux-team preamble show <agent> # Show agent's preamble
298
- tmux-team preamble set <agent> "text" # Set agent's preamble
299
- tmux-team preamble clear <agent> # Clear agent's preamble
100
+ tmux-team learn # Comprehensive guide
101
+ tmux-team help # All commands and options
300
102
  ```
301
103
 
302
- ---
303
-
304
- *Built for developers who live in the terminal and want their AIs to do the same.*
305
-
306
- ---
307
-
308
104
  ## License
309
105
 
310
106
  MIT
package/package.json CHANGED
@@ -1,12 +1,25 @@
1
1
  {
2
2
  "name": "tmux-team",
3
- "version": "3.0.1",
3
+ "version": "3.2.0",
4
4
  "description": "CLI tool for AI agent collaboration in tmux - manage cross-pane communication",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "tmux-team": "./bin/tmux-team",
8
8
  "tmt": "./bin/tmux-team"
9
9
  },
10
+ "scripts": {
11
+ "dev": "tsx src/cli.ts",
12
+ "tmt": "./bin/tmux-team",
13
+ "test": "npm run test:run",
14
+ "test:watch": "vitest",
15
+ "test:run": "vitest run --coverage && node scripts/check-coverage.mjs --threshold 95",
16
+ "lint": "oxlint src/",
17
+ "lint:fix": "oxlint src/ --fix",
18
+ "format": "prettier --write src/",
19
+ "format:check": "prettier --check src/",
20
+ "type:check": "tsc --noEmit",
21
+ "check": "npm run type:check && npm run lint && npm run format:check"
22
+ },
10
23
  "keywords": [
11
24
  "tmux",
12
25
  "cli",
@@ -38,21 +51,10 @@
38
51
  },
39
52
  "devDependencies": {
40
53
  "@types/node": "^25.0.3",
54
+ "@vitest/coverage-v8": "^1.6.1",
41
55
  "oxlint": "^1.34.0",
42
56
  "prettier": "^3.7.4",
43
57
  "typescript": "^5.3.0",
44
58
  "vitest": "^1.2.0"
45
- },
46
- "scripts": {
47
- "dev": "tsx src/cli.ts",
48
- "tmt": "./bin/tmux-team",
49
- "test": "vitest",
50
- "test:run": "vitest run",
51
- "lint": "oxlint src/",
52
- "lint:fix": "oxlint src/ --fix",
53
- "format": "prettier --write src/",
54
- "format:check": "prettier --check src/",
55
- "type:check": "tsc --noEmit",
56
- "check": "npm run type:check && npm run lint && npm run format:check"
57
59
  }
58
- }
60
+ }
package/skills/README.md CHANGED
@@ -16,22 +16,21 @@ The easiest way to add tmux-team to Claude Code is via the plugin system:
16
16
 
17
17
  This gives you `/team` and `/learn` slash commands automatically.
18
18
 
19
- ## Quick Install (Standalone Skills)
19
+ ## Quick Install
20
20
 
21
- If you prefer standalone skills without the full plugin:
21
+ Use the interactive install command:
22
22
 
23
23
  ```bash
24
- # Install for Claude Code (user-wide)
25
- tmux-team install-skill claude
24
+ # Auto-detect environment and install
25
+ tmux-team install
26
26
 
27
- # Install for OpenAI Codex (user-wide)
28
- tmux-team install-skill codex
29
-
30
- # Install to project directory (local scope)
31
- tmux-team install-skill claude --local
32
- tmux-team install-skill codex --local
27
+ # Or specify agent directly
28
+ tmux-team install claude
29
+ tmux-team install codex
33
30
  ```
34
31
 
32
+ After installation, run `tmux-team setup` to configure your agents interactively.
33
+
35
34
  ## Claude Code
36
35
 
37
36
  Claude Code uses slash commands stored in `~/.claude/commands/` (user) or `.claude/commands/` (local).
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import type { Context } from './types.js';
3
+
4
+ function makeStubContext(): Context {
5
+ return {
6
+ argv: [],
7
+ flags: { json: false, verbose: false },
8
+ ui: {
9
+ info: vi.fn(),
10
+ success: vi.fn(),
11
+ warn: vi.fn(),
12
+ error: vi.fn(),
13
+ table: vi.fn(),
14
+ json: vi.fn(),
15
+ },
16
+ config: {
17
+ mode: 'polling',
18
+ preambleMode: 'always',
19
+ defaults: { timeout: 180, pollInterval: 1, captureLines: 100, preambleEvery: 3 },
20
+ agents: {},
21
+ paneRegistry: {},
22
+ },
23
+ tmux: {
24
+ send: vi.fn(),
25
+ capture: vi.fn(),
26
+ listPanes: vi.fn(() => []),
27
+ getCurrentPaneId: vi.fn(() => null),
28
+ },
29
+ paths: {
30
+ globalDir: '/g',
31
+ globalConfig: '/g/c.json',
32
+ localConfig: '/p/t.json',
33
+ stateFile: '/g/s.json',
34
+ },
35
+ exit: ((code: number) => {
36
+ const err = new Error(`exit(${code})`);
37
+ (err as Error & { exitCode: number }).exitCode = code;
38
+ throw err;
39
+ }) as any,
40
+ };
41
+ }
42
+
43
+ describe('cli', () => {
44
+ const originalArgv = process.argv;
45
+
46
+ beforeEach(() => {
47
+ vi.restoreAllMocks();
48
+ });
49
+
50
+ afterEach(() => {
51
+ process.argv = originalArgv;
52
+ vi.restoreAllMocks();
53
+ });
54
+
55
+ it('prints completion for bash', async () => {
56
+ vi.resetModules();
57
+ process.argv = ['node', 'cli', 'completion', 'bash'];
58
+
59
+ vi.doMock('./context.js', () => ({
60
+ createContext: () => makeStubContext(),
61
+ ExitCodes: { SUCCESS: 0, ERROR: 1 },
62
+ }));
63
+ vi.doMock('./commands/completion.js', () => ({
64
+ cmdCompletion: (shell?: string) => {
65
+ console.log(`completion:${shell}`);
66
+ },
67
+ }));
68
+
69
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
70
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
71
+
72
+ await import('./cli.js');
73
+ expect(logSpy).toHaveBeenCalledWith('completion:bash');
74
+ expect(exitSpy).toHaveBeenCalledWith(0);
75
+ });
76
+
77
+ it('errors on invalid time format', async () => {
78
+ vi.resetModules();
79
+ process.argv = ['node', 'cli', 'talk', 'codex', 'hi', '--delay', 'abc'];
80
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
81
+ throw new Error(`exit(${code})`);
82
+ }) as any);
83
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
84
+
85
+ await expect(import('./cli.js')).rejects.toThrow('exit(1)');
86
+ expect(errSpy).toHaveBeenCalled();
87
+ expect(exitSpy).toHaveBeenCalledWith(1);
88
+ });
89
+
90
+ it('routes unknown command to ctx.ui.error and exits', async () => {
91
+ vi.resetModules();
92
+ process.argv = ['node', 'cli', 'nope'];
93
+
94
+ const ctx = makeStubContext();
95
+ vi.doMock('./context.js', () => ({
96
+ createContext: () => ctx,
97
+ ExitCodes: { SUCCESS: 0, ERROR: 1 },
98
+ }));
99
+
100
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
101
+ await import('./cli.js');
102
+ expect(ctx.ui.error).toHaveBeenCalled();
103
+ expect(exitSpy).toHaveBeenCalledWith(1);
104
+ });
105
+
106
+ it('handles --version by printing VERSION', async () => {
107
+ vi.resetModules();
108
+ process.argv = ['node', 'cli', '--version'];
109
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
110
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
111
+
112
+ await import('./cli.js');
113
+ // allow the dynamic import to resolve
114
+ await new Promise((r) => setTimeout(r, 0));
115
+ expect(logSpy).toHaveBeenCalled();
116
+ expect(exitSpy).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it('routes learn command and does not exit', async () => {
120
+ vi.resetModules();
121
+ process.argv = ['node', 'cli', 'learn'];
122
+
123
+ const ctx = makeStubContext();
124
+ const learnSpy = vi.fn();
125
+ vi.doMock('./context.js', () => ({
126
+ createContext: () => ctx,
127
+ ExitCodes: { SUCCESS: 0, ERROR: 1 },
128
+ }));
129
+ vi.doMock('./commands/learn.js', () => ({ cmdLearn: learnSpy }));
130
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
131
+
132
+ await import('./cli.js');
133
+ expect(learnSpy).toHaveBeenCalled();
134
+ expect(exitSpy).not.toHaveBeenCalled();
135
+ });
136
+
137
+ it('prints JSON error when --json and a command throws', async () => {
138
+ vi.resetModules();
139
+ process.argv = ['node', 'cli', 'remove', 'some-agent', '--json']; // will throw in our mocked cmdRemove
140
+
141
+ const ctx = makeStubContext();
142
+ ctx.flags.json = true;
143
+ vi.doMock('./context.js', () => ({
144
+ createContext: () => ctx,
145
+ ExitCodes: { SUCCESS: 0, ERROR: 1 },
146
+ }));
147
+ vi.doMock('./commands/remove.js', () => ({
148
+ cmdRemove: () => {
149
+ throw new Error('boom');
150
+ },
151
+ }));
152
+
153
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
154
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
155
+
156
+ await import('./cli.js');
157
+ // allow the run().catch handler to run
158
+ await new Promise((r) => setTimeout(r, 0));
159
+
160
+ expect(errSpy).toHaveBeenCalledWith(JSON.stringify({ error: 'boom' }));
161
+ expect(exitSpy).toHaveBeenCalledWith(1);
162
+ });
163
+ });