claude_swarm 0.1.16 → 0.1.17

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b71b3ce3aebae7de091dd130cda9fc4c474f538f444be15f1ee5b07501210db6
4
- data.tar.gz: 846ac3f979b5912ccbc70f1810b760f625e75b1c7e905d5d1199b893d0bc3400
3
+ metadata.gz: 3f0ab99665f31bddf2d3641525481ce2df55e910950af03290137246847bf70d
4
+ data.tar.gz: 2435170f5b61604c3131b1680f60ed8727795c58432806d9b61007813d4f5ce3
5
5
  SHA512:
6
- metadata.gz: 9f891010506baba80420357aad85a7fde6704c2ed99155e9cf32fd38cc35efd007ac19fe0e8a1df59b5f1df2d24980f7f0150551629667b42254d02bf77c4ddc
7
- data.tar.gz: 29f913521f66298ee24dbdff1b8a043d3a00abe0723080b953cb3dbc127ac6e5ecb35a8b857f61af062db9d570f3c42a90e874dcb114b42f6bee163162f96a0a
6
+ metadata.gz: ee74c188f6cc7fd31bb8705661f0913674bb687fd1eae6c2973728d2418f62bbad86054a89bd150965f39ec0bbd37ac04b2413dd22340311afd03cd5ec9d5424
7
+ data.tar.gz: a6c5fb7db4a8659619c9c7b5de000303ce91b046b85d635381f437adc92b4fd739e8d2142f0e65323346d1ba4d2607bf2b90c7d76fcf7c119bf6b9d5c431a7fc
data/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## [0.1.17]
2
+
3
+ ### Added
4
+ - **Multi-directory support**: Instances can now access multiple directories
5
+ - The `directory` field in YAML configuration now accepts either a string (single directory) or an array of strings (multiple directories)
6
+ - Additional directories are passed to Claude using the `--add-dir` flag
7
+ - The first directory in the array serves as the primary working directory
8
+ - All specified directories must exist or validation will fail
9
+ - Example: `directory: [./frontend, ./backend, ./shared]`
10
+ - **Session monitoring commands**: New commands for monitoring and managing active Claude Swarm sessions
11
+ - `claude-swarm ps`: List all active sessions with properly aligned columns showing session ID, swarm name, total cost, uptime, and directories
12
+ - `claude-swarm show SESSION_ID`: Display detailed session information including instance hierarchy and individual costs
13
+ - `claude-swarm watch SESSION_ID`: Tail session logs in real-time (uses native `tail -f`)
14
+ - `claude-swarm clean`: Remove stale session symlinks with optional age filtering (`--days N`)
15
+ - Active sessions are tracked via symlinks in `~/.claude-swarm/run/` for efficient monitoring
16
+ - Cost tracking aggregates data from `session.log.json` for accurate reporting
17
+ - Interactive main instance shows "n/a (interactive)" for cost when not available
18
+
1
19
  ## [0.1.16]
2
20
 
3
21
  ### Changed
data/README.md CHANGED
@@ -203,8 +203,8 @@ Each instance must have:
203
203
 
204
204
  Each instance can have:
205
205
 
206
- - **directory**: Working directory for this instance (can use ~ for home)
207
- - **model**: Claude model to use (opus, sonnet, haiku)
206
+ - **directory**: Working directory for this instance (can use ~ for home). Can be a string for a single directory or an array of strings for multiple directories
207
+ - **model**: Claude model to use (opus, sonnet)
208
208
  - **connections**: Array of other instances this one can communicate with
209
209
  - **allowed_tools**: Array of tools this instance can use (backward compatible with `tools`)
210
210
  - **disallowed_tools**: Array of tools to explicitly deny (takes precedence over allowed_tools)
@@ -336,7 +336,7 @@ swarm:
336
336
  database:
337
337
  description: "Database administrator managing data persistence"
338
338
  directory: ./db
339
- model: haiku
339
+ model: sonnet
340
340
  allowed_tools:
341
341
  - Read
342
342
  - Bash
@@ -398,6 +398,36 @@ swarm:
398
398
  - Read
399
399
  ```
400
400
 
401
+ #### Multi-Directory Support
402
+
403
+ Instances can have access to multiple directories using an array (uses `claude --add-dir`):
404
+
405
+ ```yaml
406
+ version: 1
407
+ swarm:
408
+ name: "Multi-Module Project"
409
+ main: fullstack_dev
410
+ instances:
411
+ fullstack_dev:
412
+ description: "Full-stack developer working across multiple modules"
413
+ directory: [./frontend, ./backend, ./shared] # Access to multiple directories
414
+ model: opus
415
+ allowed_tools: [Read, Edit, Write, Bash]
416
+ prompt: "You work across frontend, backend, and shared code modules"
417
+
418
+ documentation_writer:
419
+ description: "Documentation specialist with access to code and docs"
420
+ directory: ["./docs", "./src", "./examples"] # Multiple directories as array
421
+ model: sonnet
422
+ allowed_tools: [Read, Write, Edit]
423
+ prompt: "You maintain documentation based on code and examples"
424
+ ```
425
+
426
+ When using multiple directories:
427
+ - The first directory in the array is the primary working directory
428
+ - Additional directories are accessible via the `--add-dir` flag in Claude
429
+ - All directories must exist or the configuration will fail validation
430
+
401
431
  #### Mixed Permission Modes
402
432
 
403
433
  You can have different permission modes for different instances:
@@ -450,10 +480,6 @@ claude-swarm --prompt "Fix the bug in the payment module"
450
480
  claude-swarm --session-id 20241206_143022
451
481
  claude-swarm --session-id ~/path/to/session
452
482
 
453
- # List available sessions
454
- claude-swarm list-sessions
455
- claude-swarm list-sessions --limit 20
456
-
457
483
  # Show version
458
484
  claude-swarm version
459
485
 
@@ -464,6 +490,67 @@ claude-swarm version
464
490
  claude-swarm mcp-serve INSTANCE_NAME --config CONFIG_FILE --session-timestamp TIMESTAMP
465
491
  ```
466
492
 
493
+ ### Session Monitoring
494
+
495
+ Claude Swarm provides commands to monitor and inspect running sessions:
496
+
497
+ ```bash
498
+ # List running swarm sessions with costs and uptime
499
+ claude-swarm ps
500
+
501
+ # Show detailed information about a session including instance hierarchy
502
+ claude-swarm show 20250617_235233
503
+
504
+ # Watch live logs from a session
505
+ claude-swarm watch 20250617_235233
506
+
507
+ # Watch logs starting from the last 50 lines
508
+ claude-swarm watch 20250617_235233 -n 50
509
+
510
+ # List all available sessions (including completed ones)
511
+ claude-swarm list-sessions
512
+ claude-swarm list-sessions --limit 20
513
+
514
+ # Clean up stale session symlinks
515
+ claude-swarm clean
516
+
517
+ # Remove sessions older than 30 days
518
+ claude-swarm clean --days 30
519
+ ```
520
+
521
+ Example output from `claude-swarm ps`:
522
+ ```
523
+ āš ļø Total cost does not include the cost of the main instance
524
+
525
+ SESSION_ID SWARM_NAME TOTAL_COST UPTIME DIRECTORY
526
+ -------------------------------------------------------------------------------
527
+ 20250617_235233 Feature Development $0.3847 15m .
528
+ 20250617_143022 Bug Investigation $1.2156 1h ./shopify
529
+ 20250617_091547 Multi-Module Dev $0.8932 30m ./frontend, ./backend, ./shared
530
+ ```
531
+
532
+ Note: The total cost shown reflects only the costs of connected instances called via MCP. The main instance cost is not tracked when running interactively.
533
+
534
+ Example output from `claude-swarm show`:
535
+ ```
536
+ Session: 20250617_235233
537
+ Swarm: Feature Development
538
+ Total Cost: $0.3847 (excluding main instance)
539
+ Start Directory: /Users/paulo/project
540
+
541
+ Instance Hierarchy:
542
+ --------------------------------------------------
543
+ ā”œā”€ orchestrator [main] (orchestrator_e85036fc)
544
+ Cost: n/a (interactive) | Calls: 0
545
+ └─ test_archaeologist (test_archaeologist_c504ca5f)
546
+ Cost: $0.1925 | Calls: 1
547
+ └─ pr_analyst (pr_analyst_bfbefe56)
548
+ Cost: $0.1922 | Calls: 1
549
+
550
+ Note: Main instance (orchestrator) cost is not tracked in interactive mode.
551
+ View costs directly in the Claude interface.
552
+ ```
553
+
467
554
  ### Session Management and Restoration (Experimental)
468
555
 
469
556
  Claude Swarm provides experimental session management with restoration capabilities. **Note: This feature is experimental and has limitations - the main instance's conversation context is not fully restored.**
@@ -502,10 +589,10 @@ Resume a previous session with all instances restored to their Claude session st
502
589
 
503
590
  ```bash
504
591
  # Resume by session ID
505
- claude-swarm --session-id 20241206_143022
592
+ claude-swarm --session-id 20250617_143022
506
593
 
507
594
  # Resume by full path
508
- claude-swarm --session-id ~/.claude-swarm/sessions/my-project/20241206_143022
595
+ claude-swarm --session-id ~/.claude-swarm/sessions/my-project/20250617_143022
509
596
  ```
510
597
 
511
598
  This will:
data/claude-swarm.yml CHANGED
@@ -1,42 +1,26 @@
1
1
  version: 1
2
2
  swarm:
3
3
  name: "Swarm Name"
4
- main: lead_developer
4
+ main: claude_swarm_architect
5
5
  instances:
6
- lead_developer:
7
- description: "Lead developer who coordinates the team and makes architectural decisions"
8
- directory: .
9
- model: sonnet
10
- prompt: "You are the lead developer coordinating the team"
11
- allowed_tools: [Read, Edit, Bash, Write]
12
- connections: [frontend_dev, backend_dev]
6
+ claude_swarm_architect:
7
+ description: "Lead architect"
8
+ directory:
9
+ - .
10
+ - /Users/paulo/src/github.com/shopify-playground/claudeception
11
+ model: opus
12
+ prompt: "You are an expert in Claude swarm architecture"
13
+ vibe: true
14
+ connections: [claudeception_architect]
13
15
 
14
16
  # Example instances (uncomment and modify as needed):
15
17
 
16
- frontend_dev:
17
- description: "Frontend developer specializing in React and modern web technologies"
18
- directory: .
19
- model: sonnet
20
- prompt: "You specialize in frontend development with React, TypeScript, and modern web technologies"
21
- allowed_tools: [Read, Edit, Write, Bash]
18
+ claudeception_architect:
19
+ description: "You are an expert in Claudeception architecture"
20
+ directory:
21
+ - .
22
+ - /Users/paulo/src/github.com/shopify-playground/claudeception
23
+ model: opus
24
+ prompt: "You are an expert in Claudeception architecture"
25
+ vibe: true
22
26
 
23
- backend_dev:
24
- description: "Backend developer focusing on APIs, databases, and server architecture"
25
- directory: .
26
- model: sonnet
27
- prompt: "You specialize in backend development, APIs, databases, and server architecture"
28
- allowed_tools: [Read, Edit, Write, Bash]
29
-
30
- # devops_engineer:
31
- # description: "DevOps engineer managing infrastructure, CI/CD, and deployments"
32
- # directory: .
33
- # model: sonnet
34
- # prompt: "You specialize in infrastructure, CI/CD, containerization, and deployment"
35
- # allowed_tools: [Read, Edit, Write, Bash]
36
-
37
- # qa_engineer:
38
- # description: "QA engineer ensuring quality through comprehensive testing"
39
- # directory: ./tests
40
- # model: sonnet
41
- # prompt: "You specialize in testing, quality assurance, and test automation"
42
- # allowed_tools: [Read, Edit, Write, Bash]
@@ -0,0 +1,26 @@
1
+ version: 1
2
+ swarm:
3
+ name: "Monitoring Demo"
4
+ main: coordinator
5
+ instances:
6
+ coordinator:
7
+ description: "Main coordinator managing the team"
8
+ directory: .
9
+ model: haiku
10
+ connections: [analyzer, reporter]
11
+ prompt: "You coordinate analysis and reporting tasks"
12
+ allowed_tools: [Read, Edit]
13
+
14
+ analyzer:
15
+ description: "Data analyzer processing information"
16
+ directory: ./data
17
+ model: haiku
18
+ prompt: "You analyze data and provide insights"
19
+ allowed_tools: [Read, Bash]
20
+
21
+ reporter:
22
+ description: "Report generator creating summaries"
23
+ directory: ./reports
24
+ model: haiku
25
+ prompt: "You generate reports from analysis"
26
+ allowed_tools: [Write, Edit]
@@ -0,0 +1,26 @@
1
+ version: 1
2
+ swarm:
3
+ name: "Multi-Directory Example"
4
+ main: fullstack_dev
5
+ instances:
6
+ fullstack_dev:
7
+ description: "Full-stack developer with access to multiple project directories"
8
+ directory: [./frontend, ./backend, ./shared, ./docs]
9
+ model: opus
10
+ connections: [frontend_specialist, backend_specialist]
11
+ allowed_tools: [Read, Edit, Write, Bash, WebSearch]
12
+ prompt: "You are a full-stack developer with access to frontend, backend, shared code, and documentation directories"
13
+
14
+ frontend_specialist:
15
+ description: "Frontend developer focused on React components"
16
+ directory: ./frontend
17
+ model: sonnet
18
+ allowed_tools: [Read, Edit, Write, Bash]
19
+ prompt: "You specialize in React and frontend development"
20
+
21
+ backend_specialist:
22
+ description: "Backend developer focused on API development"
23
+ directory: ./backend
24
+ model: sonnet
25
+ allowed_tools: [Read, Edit, Write, Bash]
26
+ prompt: "You specialize in backend API development"
@@ -12,8 +12,9 @@ module ClaudeSwarm
12
12
 
13
13
  def initialize(working_directory: Dir.pwd, model: nil, mcp_config: nil, vibe: false,
14
14
  instance_name: nil, instance_id: nil, calling_instance: nil, calling_instance_id: nil,
15
- claude_session_id: nil)
15
+ claude_session_id: nil, additional_directories: [])
16
16
  @working_directory = working_directory
17
+ @additional_directories = additional_directories
17
18
  @model = model
18
19
  @mcp_config = mcp_config
19
20
  @vibe = vibe
@@ -259,6 +260,12 @@ module ClaudeSwarm
259
260
 
260
261
  cmd_array << "--verbose"
261
262
 
263
+ # Add additional directories with --add-dir
264
+ cmd_array << "--add-dir" if @additional_directories.any?
265
+ @additional_directories.each do |additional_dir|
266
+ cmd_array << additional_dir
267
+ end
268
+
262
269
  # Add MCP config if specified
263
270
  cmd_array += ["--mcp-config", @mcp_config] if @mcp_config
264
271
 
@@ -28,7 +28,8 @@ module ClaudeSwarm
28
28
  instance_id: instance_config[:instance_id],
29
29
  calling_instance: calling_instance,
30
30
  calling_instance_id: calling_instance_id,
31
- claude_session_id: instance_config[:claude_session_id]
31
+ claude_session_id: instance_config[:claude_session_id],
32
+ additional_directories: instance_config[:directories][1..] || []
32
33
  )
33
34
 
34
35
  # Set class variables so tools can access them
@@ -71,6 +71,8 @@ module ClaudeSwarm
71
71
  desc: "Instance name"
72
72
  method_option :directory, aliases: "-d", type: :string, required: true,
73
73
  desc: "Working directory for the instance"
74
+ method_option :directories, type: :array,
75
+ desc: "All directories (including main directory) for the instance"
74
76
  method_option :model, aliases: "-m", type: :string, required: true,
75
77
  desc: "Claude model to use (e.g., opus, sonnet)"
76
78
  method_option :prompt, aliases: "-p", type: :string,
@@ -101,6 +103,7 @@ module ClaudeSwarm
101
103
  instance_config = {
102
104
  name: options[:name],
103
105
  directory: options[:directory],
106
+ directories: options[:directories] || [options[:directory]],
104
107
  model: options[:model],
105
108
  prompt: options[:prompt],
106
109
  description: options[:description],
@@ -108,7 +111,7 @@ module ClaudeSwarm
108
111
  disallowed_tools: options[:disallowed_tools] || [],
109
112
  connections: options[:connections] || [],
110
113
  mcp_config_path: options[:mcp_config_path],
111
- vibe: options[:vibe],
114
+ vibe: options[:vibe] || false,
112
115
  instance_id: options[:instance_id],
113
116
  claude_session_id: options[:claude_session_id]
114
117
  }
@@ -194,6 +197,82 @@ module ClaudeSwarm
194
197
  say "Claude Swarm #{VERSION}"
195
198
  end
196
199
 
200
+ desc "ps", "List running Claude Swarm sessions"
201
+ def ps
202
+ require_relative "commands/ps"
203
+ Commands::Ps.new.execute
204
+ end
205
+
206
+ desc "show SESSION_ID", "Show detailed session information"
207
+ def show(session_id)
208
+ require_relative "commands/show"
209
+ Commands::Show.new.execute(session_id)
210
+ end
211
+
212
+ desc "clean", "Remove stale session symlinks"
213
+ method_option :days, aliases: "-d", type: :numeric, default: 7,
214
+ desc: "Remove sessions older than N days"
215
+ def clean
216
+ run_dir = File.expand_path("~/.claude-swarm/run")
217
+ unless Dir.exist?(run_dir)
218
+ say "No run directory found", :yellow
219
+ return
220
+ end
221
+
222
+ cleaned = 0
223
+ Dir.glob("#{run_dir}/*").each do |symlink|
224
+ next unless File.symlink?(symlink)
225
+
226
+ begin
227
+ # Remove if target doesn't exist (stale)
228
+ unless File.exist?(File.readlink(symlink))
229
+ File.unlink(symlink)
230
+ cleaned += 1
231
+ next
232
+ end
233
+
234
+ # Remove if older than specified days
235
+ if File.stat(symlink).mtime < Time.now - (options[:days] * 86_400)
236
+ File.unlink(symlink)
237
+ cleaned += 1
238
+ end
239
+ rescue StandardError
240
+ # Skip problematic symlinks
241
+ end
242
+ end
243
+
244
+ say "Cleaned #{cleaned} stale session#{cleaned == 1 ? "" : "s"}", :green
245
+ end
246
+
247
+ desc "watch SESSION_ID", "Watch session logs"
248
+ method_option :lines, aliases: "-n", type: :numeric, default: 100,
249
+ desc: "Number of lines to show initially"
250
+ def watch(session_id)
251
+ # Find session path
252
+ run_symlink = File.join(File.expand_path("~/.claude-swarm/run"), session_id)
253
+ session_path = if File.symlink?(run_symlink)
254
+ File.readlink(run_symlink)
255
+ else
256
+ # Search in sessions directory
257
+ Dir.glob(File.expand_path("~/.claude-swarm/sessions/*/*")).find do |path|
258
+ File.basename(path) == session_id
259
+ end
260
+ end
261
+
262
+ unless session_path && Dir.exist?(session_path)
263
+ error "Session not found: #{session_id}"
264
+ exit 1
265
+ end
266
+
267
+ log_file = File.join(session_path, "session.log")
268
+ unless File.exist?(log_file)
269
+ error "Log file not found for session: #{session_id}"
270
+ exit 1
271
+ end
272
+
273
+ exec("tail", "-f", "-n", options[:lines].to_s, log_file)
274
+ end
275
+
197
276
  desc "list-sessions", "List all available Claude Swarm sessions"
198
277
  method_option :limit, aliases: "-l", type: :numeric, default: 10,
199
278
  desc: "Maximum number of sessions to display"
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "json"
5
+ require "time"
6
+
7
+ module ClaudeSwarm
8
+ module Commands
9
+ class Ps
10
+ RUN_DIR = File.expand_path("~/.claude-swarm/run")
11
+
12
+ def execute
13
+ unless Dir.exist?(RUN_DIR)
14
+ puts "No active sessions"
15
+ return
16
+ end
17
+
18
+ sessions = []
19
+
20
+ # Read all symlinks in run directory
21
+ Dir.glob("#{RUN_DIR}/*").each do |symlink|
22
+ next unless File.symlink?(symlink)
23
+
24
+ begin
25
+ session_dir = File.readlink(symlink)
26
+ # Skip if target doesn't exist (stale symlink)
27
+ next unless Dir.exist?(session_dir)
28
+
29
+ session_info = parse_session_info(session_dir)
30
+ sessions << session_info if session_info
31
+ rescue StandardError
32
+ # Skip problematic symlinks
33
+ end
34
+ end
35
+
36
+ if sessions.empty?
37
+ puts "No active sessions"
38
+ return
39
+ end
40
+
41
+ # Column widths
42
+ col_session = 15
43
+ col_swarm = 25
44
+ col_cost = 12
45
+ col_uptime = 10
46
+
47
+ # Display header with proper spacing
48
+ header = "#{
49
+ "SESSION_ID".ljust(col_session)
50
+ } #{
51
+ "SWARM_NAME".ljust(col_swarm)
52
+ } #{
53
+ "TOTAL_COST".ljust(col_cost)
54
+ } #{
55
+ "UPTIME".ljust(col_uptime)
56
+ } DIRECTORY"
57
+ puts "\nāš ļø \e[3mTotal cost does not include the cost of the main instance\e[0m\n\n"
58
+ puts header
59
+ puts "-" * header.length
60
+
61
+ # Display sessions sorted by start time (newest first)
62
+ sessions.sort_by { |s| s[:start_time] }.reverse.each do |session|
63
+ cost_str = format("$%.4f", session[:cost])
64
+ puts "#{
65
+ session[:id].ljust(col_session)
66
+ } #{
67
+ truncate(session[:name], col_swarm).ljust(col_swarm)
68
+ } #{
69
+ cost_str.ljust(col_cost)
70
+ } #{
71
+ session[:uptime].ljust(col_uptime)
72
+ } #{session[:directory]}"
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def parse_session_info(session_dir)
79
+ session_id = File.basename(session_dir)
80
+
81
+ # Load config for swarm name and main directory
82
+ config_file = File.join(session_dir, "config.yml")
83
+ return nil unless File.exist?(config_file)
84
+
85
+ config = YAML.load_file(config_file)
86
+ swarm_name = config.dig("swarm", "name") || "Unknown"
87
+ main_instance = config.dig("swarm", "main")
88
+
89
+ # Get all directories - handle both string and array formats
90
+ dir_config = config.dig("swarm", "instances", main_instance, "directory")
91
+ directories = if dir_config.is_a?(Array)
92
+ dir_config
93
+ else
94
+ [dir_config || "."]
95
+ end
96
+ directories_str = directories.join(", ")
97
+
98
+ # Calculate total cost from JSON log
99
+ total_cost = calculate_total_cost(session_dir)
100
+
101
+ # Get uptime from directory creation time
102
+ start_time = File.stat(session_dir).ctime
103
+ uptime = format_duration(Time.now - start_time)
104
+
105
+ {
106
+ id: session_id,
107
+ name: swarm_name,
108
+ cost: total_cost,
109
+ uptime: uptime,
110
+ directory: directories_str,
111
+ start_time: start_time
112
+ }
113
+ rescue StandardError
114
+ nil
115
+ end
116
+
117
+ def calculate_total_cost(session_dir)
118
+ log_file = File.join(session_dir, "session.log.json")
119
+ return 0.0 unless File.exist?(log_file)
120
+
121
+ total = 0.0
122
+ File.foreach(log_file) do |line|
123
+ data = JSON.parse(line)
124
+ total += data["event"]["total_cost_usd"] if data.dig("event", "type") == "result" && data.dig("event", "total_cost_usd")
125
+ rescue JSON::ParserError
126
+ next
127
+ end
128
+ total
129
+ end
130
+
131
+ def format_duration(seconds)
132
+ if seconds < 60
133
+ "#{seconds.to_i}s"
134
+ elsif seconds < 3600
135
+ "#{(seconds / 60).to_i}m"
136
+ elsif seconds < 86_400
137
+ "#{(seconds / 3600).to_i}h"
138
+ else
139
+ "#{(seconds / 86_400).to_i}d"
140
+ end
141
+ end
142
+
143
+ def truncate(str, length)
144
+ str.length > length ? "#{str[0...length - 2]}.." : str
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "json"
5
+
6
+ module ClaudeSwarm
7
+ module Commands
8
+ class Show
9
+ def execute(session_id)
10
+ session_path = find_session_path(session_id)
11
+ unless session_path
12
+ puts "Session not found: #{session_id}"
13
+ exit 1
14
+ end
15
+
16
+ # Load config to get main instance name
17
+ config = YAML.load_file(File.join(session_path, "config.yml"))
18
+ main_instance_name = config.dig("swarm", "main")
19
+
20
+ # Parse all events to build instance data
21
+ instances = parse_instance_hierarchy(session_path, main_instance_name)
22
+
23
+ # Calculate total cost (excluding main if not available)
24
+ total_cost = instances.values.sum { |i| i[:cost] }
25
+ cost_display = if instances[main_instance_name] && instances[main_instance_name][:has_cost_data]
26
+ format("$%.4f", total_cost)
27
+ else
28
+ "#{format("$%.4f", total_cost)} (excluding main instance)"
29
+ end
30
+
31
+ # Display session info
32
+ puts "Session: #{session_id}"
33
+ puts "Swarm: #{config.dig("swarm", "name")}"
34
+ puts "Total Cost: #{cost_display}"
35
+
36
+ # Try to read start directory
37
+ start_dir_file = File.join(session_path, "start_directory")
38
+ puts "Start Directory: #{File.read(start_dir_file).strip}" if File.exist?(start_dir_file)
39
+
40
+ puts
41
+ puts "Instance Hierarchy:"
42
+ puts "-" * 50
43
+
44
+ # Find root instances
45
+ roots = instances.values.select { |i| i[:called_by].empty? }
46
+ roots.each do |instance|
47
+ display_instance_tree(instance, instances, 0, main_instance_name)
48
+ end
49
+
50
+ # Add note about interactive main instance
51
+ return if instances[main_instance_name]&.dig(:has_cost_data)
52
+
53
+ puts
54
+ puts "Note: Main instance (#{main_instance_name}) cost is not tracked in interactive mode."
55
+ puts " View costs directly in the Claude interface."
56
+ end
57
+
58
+ private
59
+
60
+ def find_session_path(session_id)
61
+ # First check the run directory
62
+ run_symlink = File.join(File.expand_path("~/.claude-swarm/run"), session_id)
63
+ if File.symlink?(run_symlink)
64
+ target = File.readlink(run_symlink)
65
+ return target if Dir.exist?(target)
66
+ end
67
+
68
+ # Fall back to searching all sessions
69
+ Dir.glob(File.expand_path("~/.claude-swarm/sessions/*/*")).find do |path|
70
+ File.basename(path) == session_id
71
+ end
72
+ end
73
+
74
+ def parse_instance_hierarchy(session_path, _main_instance_name)
75
+ log_file = File.join(session_path, "session.log.json")
76
+ instances = {}
77
+
78
+ return instances unless File.exist?(log_file)
79
+
80
+ File.foreach(log_file) do |line|
81
+ data = JSON.parse(line)
82
+ instance_name = data["instance"]
83
+ instance_id = data["instance_id"]
84
+ calling_instance = data["calling_instance"]
85
+
86
+ # Initialize instance data
87
+ instances[instance_name] ||= {
88
+ name: instance_name,
89
+ id: instance_id,
90
+ cost: 0.0,
91
+ calls: 0,
92
+ called_by: Set.new,
93
+ calls_to: Set.new,
94
+ has_cost_data: false
95
+ }
96
+
97
+ # Track relationships
98
+ if calling_instance && calling_instance != instance_name
99
+ instances[instance_name][:called_by] << calling_instance
100
+
101
+ instances[calling_instance] ||= {
102
+ name: calling_instance,
103
+ id: data["calling_instance_id"],
104
+ cost: 0.0,
105
+ calls: 0,
106
+ called_by: Set.new,
107
+ calls_to: Set.new,
108
+ has_cost_data: false
109
+ }
110
+ instances[calling_instance][:calls_to] << instance_name
111
+ end
112
+
113
+ # Track costs and calls
114
+ if data.dig("event", "type") == "result"
115
+ instances[instance_name][:calls] += 1
116
+ if (cost = data.dig("event", "total_cost_usd"))
117
+ instances[instance_name][:cost] += cost
118
+ instances[instance_name][:has_cost_data] = true
119
+ end
120
+ end
121
+ rescue JSON::ParserError
122
+ next
123
+ end
124
+
125
+ instances
126
+ end
127
+
128
+ def display_instance_tree(instance, all_instances, level, main_instance_name)
129
+ indent = " " * level
130
+ prefix = level.zero? ? "ā”œā”€" : "└─"
131
+
132
+ # Display instance name with special marker for main
133
+ instance_display = instance[:name]
134
+ instance_display += " [main]" if instance[:name] == main_instance_name
135
+
136
+ puts "#{indent}#{prefix} #{instance_display} (#{instance[:id]})"
137
+
138
+ # Display cost - show n/a for main instance without cost data
139
+ cost_display = if instance[:name] == main_instance_name && !instance[:has_cost_data]
140
+ "n/a (interactive)"
141
+ else
142
+ format("$%.4f", instance[:cost])
143
+ end
144
+
145
+ puts "#{indent} Cost: #{cost_display} | Calls: #{instance[:calls]}"
146
+
147
+ # Display children
148
+ children = instance[:calls_to].map { |name| all_instances[name] }.compact
149
+ children.each do |child|
150
+ # Don't recurse if we've already shown this instance (avoid cycles)
151
+ next if level.positive? && child[:called_by].size > 1
152
+
153
+ display_instance_tree(child, all_instances, level + 1, main_instance_name)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -86,9 +86,13 @@ module ClaudeSwarm
86
86
  # Support both 'tools' (deprecated) and 'allowed_tools' for backward compatibility
87
87
  allowed_tools = config["allowed_tools"] || config["tools"] || []
88
88
 
89
+ # Parse directory field - support both string and array
90
+ directories = parse_directories(config["directory"])
91
+
89
92
  {
90
93
  name: name,
91
- directory: expand_path(config["directory"] || "."),
94
+ directory: directories.first, # Keep single directory for backward compatibility
95
+ directories: directories, # New field with all directories
92
96
  model: config["model"] || "sonnet",
93
97
  connections: Array(config["connections"]),
94
98
  tools: Array(allowed_tools), # Keep as 'tools' internally for compatibility
@@ -156,8 +160,10 @@ module ClaudeSwarm
156
160
 
157
161
  def validate_directories
158
162
  @instances.each do |name, instance|
159
- directory = instance[:directory]
160
- raise Error, "Directory '#{directory}' for instance '#{name}' does not exist" unless File.directory?(directory)
163
+ # Validate all directories in the directories array
164
+ instance[:directories].each do |directory|
165
+ raise Error, "Directory '#{directory}' for instance '#{name}' does not exist" unless File.directory?(directory)
166
+ end
161
167
  end
162
168
  end
163
169
 
@@ -168,6 +174,17 @@ module ClaudeSwarm
168
174
  raise Error, "Instance '#{instance_name}' field '#{field_name}' must be an array, got #{field_value.class.name}" unless field_value.is_a?(Array)
169
175
  end
170
176
 
177
+ def parse_directories(directory_config)
178
+ # Default to current directory if not specified
179
+ directory_config ||= "."
180
+
181
+ # Convert to array and expand paths
182
+ directories = Array(directory_config).map { |dir| expand_path(dir) }
183
+
184
+ # Ensure at least one directory
185
+ directories.empty? ? [expand_path(".")] : directories
186
+ end
187
+
171
188
  def expand_path(path)
172
189
  Pathname.new(path).expand_path(@base_dir).to_s
173
190
  end
@@ -107,6 +107,9 @@ module ClaudeSwarm
107
107
  "--model", instance[:model]
108
108
  ]
109
109
 
110
+ # Add directories array if we have multiple directories
111
+ args.push("--directories", *instance[:directories]) if instance[:directories] && instance[:directories].size > 1
112
+
110
113
  # Add optional arguments
111
114
  args.push("--prompt", instance[:prompt]) if instance[:prompt]
112
115
 
@@ -8,6 +8,8 @@ require_relative "process_tracker"
8
8
 
9
9
  module ClaudeSwarm
10
10
  class Orchestrator
11
+ RUN_DIR = File.expand_path("~/.claude-swarm/run")
12
+
11
13
  def initialize(configuration, mcp_generator, vibe: false, prompt: nil, stream_logs: false, debug: false,
12
14
  restore_session_path: nil)
13
15
  @config = configuration
@@ -17,6 +19,7 @@ module ClaudeSwarm
17
19
  @stream_logs = stream_logs
18
20
  @debug = debug
19
21
  @restore_session_path = restore_session_path
22
+ @session_path = nil
20
23
  end
21
24
 
22
25
  def start
@@ -29,9 +32,13 @@ module ClaudeSwarm
29
32
 
30
33
  # Use existing session path
31
34
  session_path = @restore_session_path
35
+ @session_path = session_path
32
36
  ENV["CLAUDE_SWARM_SESSION_PATH"] = session_path
33
37
  ENV["CLAUDE_SWARM_START_DIR"] = Dir.pwd
34
38
 
39
+ # Create run symlink for restored session
40
+ create_run_symlink
41
+
35
42
  unless @prompt
36
43
  puts "šŸ“ Using existing session: #{session_path}/"
37
44
  puts
@@ -59,10 +66,14 @@ module ClaudeSwarm
59
66
  # Generate and set session path for all instances
60
67
  session_path = SessionPath.generate(working_dir: Dir.pwd)
61
68
  SessionPath.ensure_directory(session_path)
69
+ @session_path = session_path
62
70
 
63
71
  ENV["CLAUDE_SWARM_SESSION_PATH"] = session_path
64
72
  ENV["CLAUDE_SWARM_START_DIR"] = Dir.pwd
65
73
 
74
+ # Create run symlink for new session
75
+ create_run_symlink
76
+
66
77
  unless @prompt
67
78
  puts "šŸ“ Session files will be saved to: #{session_path}/"
68
79
  puts
@@ -90,7 +101,12 @@ module ClaudeSwarm
90
101
  unless @prompt
91
102
  puts "šŸš€ Launching main instance: #{@config.main_instance}"
92
103
  puts " Model: #{main_instance[:model]}"
93
- puts " Directory: #{main_instance[:directory]}"
104
+ if main_instance[:directories].size == 1
105
+ puts " Directory: #{main_instance[:directory]}"
106
+ else
107
+ puts " Directories:"
108
+ main_instance[:directories].each { |dir| puts " - #{dir}" }
109
+ end
94
110
  puts " Allowed tools: #{main_instance[:allowed_tools].join(", ")}" if main_instance[:allowed_tools].any?
95
111
  puts " Disallowed tools: #{main_instance[:disallowed_tools].join(", ")}" if main_instance[:disallowed_tools]&.any?
96
112
  puts " Connections: #{main_instance[:connections].join(", ")}" if main_instance[:connections].any?
@@ -119,8 +135,9 @@ module ClaudeSwarm
119
135
  log_thread.join
120
136
  end
121
137
 
122
- # Clean up child processes
138
+ # Clean up child processes and run symlink
123
139
  cleanup_processes
140
+ cleanup_run_symlink
124
141
  end
125
142
 
126
143
  private
@@ -140,6 +157,7 @@ module ClaudeSwarm
140
157
  Signal.trap(signal) do
141
158
  puts "\nšŸ›‘ Received #{signal} signal, cleaning up..."
142
159
  cleanup_processes
160
+ cleanup_run_symlink
143
161
  exit
144
162
  end
145
163
  end
@@ -152,6 +170,35 @@ module ClaudeSwarm
152
170
  puts "āš ļø Error during cleanup: #{e.message}"
153
171
  end
154
172
 
173
+ def create_run_symlink
174
+ return unless @session_path
175
+
176
+ FileUtils.mkdir_p(RUN_DIR)
177
+
178
+ # Session ID is the last part of the session path
179
+ session_id = File.basename(@session_path)
180
+ symlink_path = File.join(RUN_DIR, session_id)
181
+
182
+ # Remove stale symlink if exists
183
+ File.unlink(symlink_path) if File.symlink?(symlink_path)
184
+
185
+ # Create new symlink
186
+ File.symlink(@session_path, symlink_path)
187
+ rescue StandardError => e
188
+ # Don't fail the process if symlink creation fails
189
+ puts "āš ļø Warning: Could not create run symlink: #{e.message}" unless @prompt
190
+ end
191
+
192
+ def cleanup_run_symlink
193
+ return unless @session_path
194
+
195
+ session_id = File.basename(@session_path)
196
+ symlink_path = File.join(RUN_DIR, session_id)
197
+ File.unlink(symlink_path) if File.symlink?(symlink_path)
198
+ rescue StandardError
199
+ # Ignore errors during cleanup
200
+ end
201
+
155
202
  def start_log_streaming
156
203
  Thread.new do
157
204
  session_log_path = File.join(ENV.fetch("CLAUDE_SWARM_SESSION_PATH", nil), "session.log")
@@ -238,6 +285,14 @@ module ClaudeSwarm
238
285
 
239
286
  parts << "--debug" if @debug
240
287
 
288
+ # Add additional directories with --add-dir
289
+ if instance[:directories].size > 1
290
+ instance[:directories][1..].each do |additional_dir|
291
+ parts << "--add-dir"
292
+ parts << additional_dir
293
+ end
294
+ end
295
+
241
296
  mcp_config_path = @generator.mcp_config_path(@config.main_instance)
242
297
  parts << "--mcp-config"
243
298
  parts << mcp_config_path
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeSwarm
4
- VERSION = "0.1.16"
4
+ VERSION = "0.1.17"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude_swarm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.16
4
+ version: 0.1.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Arruda
@@ -65,11 +65,15 @@ files:
65
65
  - example/microservices-team.yml
66
66
  - example/session-restoration-demo.yml
67
67
  - example/test-generation.yml
68
+ - examples/monitoring-demo.yml
69
+ - examples/multi-directory.yml
68
70
  - exe/claude-swarm
69
71
  - lib/claude_swarm.rb
70
72
  - lib/claude_swarm/claude_code_executor.rb
71
73
  - lib/claude_swarm/claude_mcp_server.rb
72
74
  - lib/claude_swarm/cli.rb
75
+ - lib/claude_swarm/commands/ps.rb
76
+ - lib/claude_swarm/commands/show.rb
73
77
  - lib/claude_swarm/configuration.rb
74
78
  - lib/claude_swarm/mcp_generator.rb
75
79
  - lib/claude_swarm/orchestrator.rb