brainiac 0.0.27 → 0.0.28

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: 889b0c7491371dd4deb7dc5a173eb5c45045cf9d85a9bf1b5cfcf75bb233d407
4
- data.tar.gz: c5532f51f711e5b5839fd3c9cbfe3e297e8ae00849a44b9b82331d13d5d318f0
3
+ metadata.gz: add3489f7ad183254936da1957e45221696ba277bc1119fd243cdde74c8114a8
4
+ data.tar.gz: 9fbc0a541c50978c549327528f4f64383735b386dd6688a9d38dae4353a04542
5
5
  SHA512:
6
- metadata.gz: 81735abd0d5d4dc9e9aeccaf3ae80e7c51f3ca529f759750e0dc8ad4f3f15a3bee54ea3169f96e9a6e3a7b378d8395940e9b5af10bd92e8117d0fc49a602d855
7
- data.tar.gz: 71bfdf3580bbe77aa873be4d7ef211b42f9f19979efce7a76cdcbfdeb30c914bdf9dae48b32d571bf28ad9871f53c1fd92946f025e36d7d9aa2a4e2b38ad4df5
6
+ metadata.gz: 063130f40cb32c62a13e3fb5cf1c036d88e17f25e76243c3724f56e49776ffbaed5f025efc8a03b6792da24917d615665f5169147dfa3e28ed4ac9da5c63b1c7
7
+ data.tar.gz: a57864221e958f731ce29d77a9a2322eda834f4efd63bd83109dd326bcafbe749592a47fe29b1bc76ada0e51dc74380f76079802c3881b9465e66d2aea4cc653
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- brainiac (0.0.27)
4
+ brainiac (0.0.28)
5
5
  puma (~> 7.2)
6
6
  rackup (~> 2.3)
7
7
  sinatra (~> 4.1)
@@ -84,7 +84,7 @@ DEPENDENCIES
84
84
  CHECKSUMS
85
85
  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
86
86
  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
87
- brainiac (0.0.27)
87
+ brainiac (0.0.28)
88
88
  json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
89
89
  language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
90
90
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
data/README.md CHANGED
@@ -226,12 +226,23 @@ Configure which AI CLI to use for dispatching agents. Each provider is a JSON fi
226
226
 
227
227
  ```json
228
228
  {
229
- "agent_cli": "kiro-cli",
230
- "agent_cli_args": "chat --trust-all-tools --no-interactive",
231
- "agent_model_flag": "--model"
229
+ "binary": "kiro-cli",
230
+ "default_args": "chat --trust-all-tools --no-interactive",
231
+ "agent_flag": "--agent",
232
+ "model_flag": "--model"
232
233
  }
233
234
  ```
234
235
 
236
+ The `agent_flag` field controls how agent identity is passed to the CLI. Different CLIs use different flags:
237
+
238
+ | CLI | `agent_flag` | Example command |
239
+ |-----|-------------|-----------------|
240
+ | Kiro CLI | `"--agent"` | `kiro-cli --agent sherlock chat --no-interactive` |
241
+ | Codex CLI | `"--profile"` | `codex --profile sherlock exec --sandbox workspace-write -` |
242
+ | Grok CLI | `null` | `grok --always-approve` (no agent identity passed) |
243
+
244
+ Set `agent_flag` to `null` to suppress passing the agent name entirely (for CLIs that don't support persona switching).
245
+
235
246
  Brainiac ships with example providers during setup. Your project config references which provider to use.
236
247
 
237
248
  #### 3. Environment Variables
data/bin/brainiac CHANGED
@@ -205,6 +205,7 @@ def wait_for_agents_to_finish
205
205
  end
206
206
  end
207
207
 
208
+ # rubocop:disable Naming/PredicateMethod
208
209
  def stop_server
209
210
  pid = find_server_pid
210
211
 
@@ -245,6 +246,7 @@ def stop_server
245
246
  false
246
247
  end
247
248
  end
249
+ # rubocop:enable Naming/PredicateMethod
248
250
 
249
251
  # Kill a single process: TERM first, KILL if it doesn't exit within 5s.
250
252
  def kill_pid(pid, quiet: false)
@@ -1025,6 +1027,9 @@ def brain_list
1025
1027
  puts "Run 'brainiac brain init <agent-name>' to set up a new agent."
1026
1028
  end
1027
1029
 
1030
+ # Model output parsing (shared with lib/brainiac/helpers.rb)
1031
+ require_relative "../lib/brainiac/model_parser"
1032
+
1028
1033
  # Main CLI
1029
1034
  options = {}
1030
1035
  subcommand = ARGV.shift
@@ -1940,6 +1945,150 @@ when "provider"
1940
1945
  File.write(file, JSON.pretty_generate(scaffold))
1941
1946
  puts "Created #{file} — edit it to configure the provider."
1942
1947
 
1948
+ when "list-models"
1949
+ name = ARGV[0]
1950
+ unless name
1951
+ puts "Usage: brainiac provider list-models <name>"
1952
+ exit 1
1953
+ end
1954
+ file = File.join(providers_dir, "#{name}.json")
1955
+ unless File.exist?(file)
1956
+ puts "Provider '#{name}' not found."
1957
+ exit 1
1958
+ end
1959
+ data = JSON.parse(File.read(file))
1960
+ command = data["list_models_command"]
1961
+ if command.nil? || command.empty?
1962
+ puts "Provider '#{name}' has no list_models_command configured."
1963
+ puts "Edit #{file} and set \"list_models_command\" to a shell command that lists models."
1964
+ puts "Example: \"kiro-cli chat --list-models --format json\""
1965
+ exit 1
1966
+ end
1967
+
1968
+ puts "Querying models from '#{name}' (#{command})..."
1969
+ puts ""
1970
+ stdout, stderr, status = Open3.capture3(command)
1971
+ unless status.success?
1972
+ puts "Command failed (exit #{status.exitstatus}):"
1973
+ puts stderr.strip unless stderr.empty?
1974
+ exit 1
1975
+ end
1976
+
1977
+ models = parse_list_models_output(stdout)
1978
+ if models.nil? || models.empty?
1979
+ puts "No models found in command output."
1980
+ puts "Raw output:"
1981
+ puts stdout
1982
+ exit 1
1983
+ end
1984
+
1985
+ # Filter hidden models (e.g. codex "visibility": "hide") unless none are visible
1986
+ visible_models = models.select { |m| m["visibility"].nil? || m["visibility"] != "hide" }
1987
+ visible_models = models if visible_models.empty?
1988
+
1989
+ # Display models in a table
1990
+ default_model = data["default_model"]
1991
+ # Determine default: explicit default_model field > parsed "default" flag > first model
1992
+ has_explicit_default = default_model && visible_models.any? { |m| m["model_id"] == default_model }
1993
+ has_parsed_default = !has_explicit_default && visible_models.any? { |m| m["default"] }
1994
+ puts "Available models for '#{name}' (#{visible_models.size} visible, #{models.size} total):"
1995
+ puts ""
1996
+ visible_models.each do |m|
1997
+ model_id = m["model_id"] || "unknown"
1998
+ desc = m["description"] || m["display_name"] || ""
1999
+ rate = m["rate_multiplier"] ? "#{m["rate_multiplier"]}x" : (m["rate"] || "")
2000
+ marker = if has_explicit_default
2001
+ m["model_id"] == default_model ? "*" : " "
2002
+ elsif has_parsed_default
2003
+ m["default"] ? "*" : " "
2004
+ else
2005
+ m == visible_models.first ? "*" : " "
2006
+ end
2007
+ if desc.empty?
2008
+ puts " #{marker} #{model_id}"
2009
+ else
2010
+ puts format(" %<marker>s %-25<model>s %-15<rate>s %<desc>s", marker: marker, model: model_id, rate: rate, desc: desc)
2011
+ end
2012
+ end
2013
+
2014
+ # Show which are mapped vs unmapped
2015
+ configured = data["models"] || {}
2016
+ configured_ids = configured.values
2017
+ unmapped = visible_models.select do |m|
2018
+ id = m["model_id"]
2019
+ !configured_ids.include?(id) && !configured.key?(id)
2020
+ end
2021
+ unless unmapped.empty?
2022
+ puts ""
2023
+ puts "#{unmapped.size} model(s) not in your provider's 'models' map:"
2024
+ unmapped.each { |m| puts " - #{m["model_id"]}" }
2025
+ puts ""
2026
+ puts "Run 'brainiac provider sync-models #{name}' to add them."
2027
+ end
2028
+
2029
+ when "sync-models"
2030
+ name = ARGV[0]
2031
+ unless name
2032
+ puts "Usage: brainiac provider sync-models <name>"
2033
+ exit 1
2034
+ end
2035
+ file = File.join(providers_dir, "#{name}.json")
2036
+ unless File.exist?(file)
2037
+ puts "Provider '#{name}' not found."
2038
+ exit 1
2039
+ end
2040
+ data = JSON.parse(File.read(file))
2041
+ command = data["list_models_command"]
2042
+ if command.nil? || command.empty?
2043
+ puts "Provider '#{name}' has no list_models_command configured."
2044
+ exit 1
2045
+ end
2046
+
2047
+ stdout, stderr, status = Open3.capture3(command)
2048
+ unless status.success?
2049
+ puts "Command failed (exit #{status.exitstatus}): #{stderr.strip}"
2050
+ exit 1
2051
+ end
2052
+
2053
+ models = parse_list_models_output(stdout)
2054
+ if models.nil? || models.empty?
2055
+ puts "No models found in command output."
2056
+ exit 1
2057
+ end
2058
+
2059
+ # Build a models map from discovered models: key = short name, value = model_id
2060
+ existing_models = data["models"] || {}
2061
+ new_models = existing_models.dup
2062
+ added = []
2063
+
2064
+ models.each do |m|
2065
+ model_id = m["model_id"]
2066
+ next unless model_id
2067
+
2068
+ # Skip hidden models (e.g. codex internal models)
2069
+ next if m["visibility"] == "hide"
2070
+
2071
+ # Skip if already mapped (as key or value)
2072
+ next if new_models.value?(model_id) || new_models.key?(model_id)
2073
+
2074
+ # Generate a short key: strip common prefixes, use the base name
2075
+ short_key = generate_short_model_key(model_id)
2076
+ # If the short key already exists, use the full model_id as the key
2077
+ short_key = model_id if new_models.key?(short_key)
2078
+ new_models[short_key] = model_id
2079
+ added << "#{short_key} => #{model_id}"
2080
+ end
2081
+
2082
+ if added.empty?
2083
+ puts "All discovered models are already mapped. Nothing to sync."
2084
+ exit 0
2085
+ end
2086
+
2087
+ data["models"] = new_models
2088
+ File.write(file, JSON.pretty_generate(data))
2089
+ puts "Synced #{added.size} new model(s) to #{file}:"
2090
+ added.each { |a| puts " + #{a}" }
2091
+
1943
2092
  else
1944
2093
  puts <<~HELP
1945
2094
  Usage: brainiac provider <command>
@@ -1948,6 +2097,8 @@ when "provider"
1948
2097
  list List configured CLI providers
1949
2098
  show <name> Show provider configuration
1950
2099
  add <name> Create a new provider config
2100
+ list-models <name> Query available models from the CLI provider
2101
+ sync-models <name> Add discovered models to provider config
1951
2102
  HELP
1952
2103
  end
1953
2104
 
@@ -3474,7 +3625,7 @@ when "plugin"
3474
3625
  gem_name = "brainiac-#{plugin_name}"
3475
3626
 
3476
3627
  # Determine the base repo path (strip worktree suffix like --branch-name)
3477
- base_path = current_path.sub(/--[^\/]+$/, "")
3628
+ base_path = current_path.sub(%r{--[^/]+$}, "")
3478
3629
  unless Dir.exist?(base_path)
3479
3630
  puts "Error: Base repo not found at #{base_path}"
3480
3631
  exit 1
@@ -3503,9 +3654,7 @@ when "plugin"
3503
3654
  # Fetch latest to make sure the branch is available
3504
3655
  puts "Fetching latest from origin..."
3505
3656
  _out, _err, fetch_status = Open3.capture3("git", "fetch", "origin", chdir: base_path)
3506
- unless fetch_status.success?
3507
- puts "Warning: git fetch failed — continuing with local state."
3508
- end
3657
+ puts "Warning: git fetch failed — continuing with local state." unless fetch_status.success?
3509
3658
 
3510
3659
  # Check if a worktree already exists for this branch
3511
3660
  worktree_list, _, wt_status = Open3.capture3("git", "worktree", "list", "--porcelain", chdir: base_path)
@@ -3513,18 +3662,19 @@ when "plugin"
3513
3662
 
3514
3663
  if wt_status.success?
3515
3664
  # Parse porcelain output to find worktrees on the target branch
3516
- worktrees = worktree_list.split("\n\n").map do |block|
3665
+ worktree_blocks = worktree_list.split("\n\n").map do |block|
3517
3666
  wt = {}
3518
3667
  block.each_line do |line|
3519
3668
  case line
3520
3669
  when /^worktree (.+)/
3521
3670
  wt[:path] = Regexp.last_match(1).strip
3522
- when /^branch refs\/heads\/(.+)/
3671
+ when %r{^branch refs/heads/(.+)}
3523
3672
  wt[:branch] = Regexp.last_match(1).strip
3524
3673
  end
3525
3674
  end
3526
3675
  wt
3527
- end.select { |wt| wt[:path] && wt[:branch] }
3676
+ end
3677
+ worktrees = worktree_blocks.select { |wt| wt[:path] && wt[:branch] }
3528
3678
 
3529
3679
  target_worktree = worktrees.find { |wt| wt[:branch] == branch_name }
3530
3680
  end
@@ -3541,11 +3691,9 @@ when "plugin"
3541
3691
  File.write(plugins_file, JSON.pretty_generate(plugins_config))
3542
3692
  puts "✓ Switched plugin '#{plugin_name}' to branch '#{branch_name}'"
3543
3693
  puts " Path: #{new_path}"
3544
- puts " Restart the server to apply: brainiac restart"
3545
3694
  else
3546
3695
  # Check if branch exists remotely or locally
3547
- branch_exists = false
3548
- check_cmd, _, check_status = Open3.capture3("git", "rev-parse", "--verify", "origin/#{branch_name}", chdir: base_path)
3696
+ _, _, check_status = Open3.capture3("git", "rev-parse", "--verify", "origin/#{branch_name}", chdir: base_path)
3549
3697
  branch_exists = check_status.success?
3550
3698
 
3551
3699
  unless branch_exists
@@ -3576,7 +3724,10 @@ when "plugin"
3576
3724
  _, _, local_check = Open3.capture3("git", "rev-parse", "--verify", branch_name, chdir: base_path)
3577
3725
  if !local_check.success? && check_status.success?
3578
3726
  # Remote branch exists but not local — create tracking branch in worktree
3579
- _, stderr, wt_status = Open3.capture3("git", "worktree", "add", "--track", "-b", branch_name, worktree_dir, "origin/#{branch_name}", chdir: base_path)
3727
+ _, stderr, wt_status = Open3.capture3(
3728
+ "git", "worktree", "add", "--track", "-b", branch_name, worktree_dir,
3729
+ "origin/#{branch_name}", chdir: base_path
3730
+ )
3580
3731
  else
3581
3732
  # Local branch exists — use it directly
3582
3733
  _, stderr, wt_status = Open3.capture3("git", "worktree", "add", worktree_dir, branch_name, chdir: base_path)
@@ -3592,8 +3743,8 @@ when "plugin"
3592
3743
  File.write(plugins_file, JSON.pretty_generate(plugins_config))
3593
3744
  puts "✓ Switched plugin '#{plugin_name}' to branch '#{branch_name}'"
3594
3745
  puts " Worktree: #{worktree_dir}"
3595
- puts " Restart the server to apply: brainiac restart"
3596
3746
  end
3747
+ puts " Restart the server to apply: brainiac restart"
3597
3748
 
3598
3749
  else
3599
3750
  puts "Usage: brainiac plugin <command>"
data/docs/resume.md ADDED
@@ -0,0 +1,87 @@
1
+ # Resume Session Integration
2
+
3
+ Brainiac supports resuming prior agent sessions instead of starting fresh. Two patterns exist:
4
+
5
+ ## Flag-Based Resume (e.g. Kiro `--resume`, Grok `-c`)
6
+
7
+ The CLI appends a flag to the existing command. The base command structure stays the same.
8
+
9
+ ```
10
+ kiro-cli --agent sherlock chat --trust-all-tools --no-interactive --resume
11
+ grok --always-approve -c
12
+ ```
13
+
14
+ Provider config:
15
+
16
+ ```json
17
+ {
18
+ "resume_flag": "--resume"
19
+ }
20
+ ```
21
+
22
+ ## Subcommand-Based Resume (e.g. Codex `exec resume --last`)
23
+
24
+ The CLI changes the subcommand entirely. The base command structure is replaced.
25
+
26
+ ```
27
+ codex exec --full-auto → codex exec resume --last --full-auto
28
+ ```
29
+
30
+ Provider config:
31
+
32
+ ```json
33
+ {
34
+ "resume_flag": null,
35
+ "resume_args": "exec resume --last --full-auto",
36
+ "session_dir": "~/.codex/sessions"
37
+ }
38
+ ```
39
+
40
+ When `resume_args` is set, it replaces `default_args` entirely during resume. The `session_dir` field tells Brainiac where to look for prior sessions (for CLIs that store session state centrally rather than in the project directory).
41
+
42
+ ## Plugin Integration Contract
43
+
44
+ Plugins that want to support resume MUST:
45
+
46
+ 1. **Use `resume_viable?`** to check whether a session can be resumed:
47
+
48
+ ```ruby
49
+ can_resume = resume_viable?(project_config: config, chdir: worktree_path)
50
+ ```
51
+
52
+ 2. **Pass `resume: true` to `run_agent`** — core handles the rest:
53
+
54
+ ```ruby
55
+ run_agent(prompt, project_config: config, chdir: worktree_path, resume: can_resume)
56
+ ```
57
+
58
+ Plugins MUST NOT:
59
+ - Check `resolved["resume_flag"]` directly (misses `resume_args` providers)
60
+ - Call `resolve_resume` directly (internal to `run_agent`)
61
+ - Build their own resume command logic
62
+
63
+ ## How It Works Internally
64
+
65
+ ```
66
+ Plugin calls run_agent(..., resume: true)
67
+ → run_agent calls resolve_resume(true, resolved, chdir)
68
+ → resolve_resume checks resume_flag OR resume_args is configured
69
+ → resolve_resume calls prior_session_exists?(chdir, cli, session_dir:)
70
+ → For centralized session dirs: scans .jsonl files for matching cwd
71
+ → For local session dirs: checks for .grok/, .kiro-cli/, or recent logs
72
+ → Returns :resume_args, flag string, or false
73
+ → run_agent passes result to build_agent_cmd
74
+ → :resume_args → replace default_args with resume_args
75
+ → String flag → append to command
76
+ → false → normal command (no resume)
77
+ ```
78
+
79
+ ## Session Detection
80
+
81
+ ### Local (flag-based CLIs)
82
+
83
+ Checks for CLI-specific dotdirs (`.grok/`, `.kiro-cli/`) or recent agent logs in `tmp/` (last 24 hours).
84
+
85
+ ### Centralized (subcommand-based CLIs)
86
+
87
+ Scans `session_dir` for `.jsonl` files modified in the last 24 hours whose first-line JSON metadata contains a `payload.cwd` matching the target working directory. Handles symlink resolution (e.g. `/var/folders` vs `/private/var/folders` on macOS).
data/lib/brainiac/cron.rb CHANGED
@@ -596,10 +596,12 @@ def build_cron_agent_cmd(job, project, prompt_file: nil)
596
596
  resolved = resolve_project_cli_config(project, agent_name: job[:agent])
597
597
  agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
598
598
  cmd = [resolved["agent_cli"]]
599
+ # cwd_flag: pass the working directory as a CLI argument (e.g. -C for Codex CLI).
600
+ cmd.push(resolved["cwd_flag"], project["repo_path"]) if resolved["cwd_flag"] && project["repo_path"]
599
601
  cmd.push(agent_flag, agent_config_name) if agent_flag
600
602
  cmd.concat(resolved["agent_cli_args"].split)
601
603
  cmd.push(resolved["agent_model_flag"], job[:model]) if resolved["agent_model_flag"]&.length&.positive? && job[:model]
602
- cmd.push(resolved["agent_effort_flag"], job[:effort]) if resolved["agent_effort_flag"]&.length&.positive? && job[:effort]
604
+ append_effort_to_cmd(cmd, job[:effort], resolved)
603
605
  cmd.push(resolved["prompt_flag"], prompt_file) if prompt_file && resolved["prompt_mode"] == "flag" && resolved["prompt_flag"]
604
606
  cmd
605
607
  end
@@ -172,37 +172,12 @@ def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path:
172
172
  base_ref ||= "origin/#{get_default_branch(repo_path)}"
173
173
 
174
174
  worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
175
-
176
- if File.directory?(worktree_path)
177
- is_tracked = worktree_list.include?(worktree_path)
178
-
179
- if is_tracked
180
- LOG.info "Worktree directory #{worktree_path} is tracked by git"
181
- else
182
- LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it"
183
- begin
184
- FileUtils.rm_rf(worktree_path)
185
- LOG.info "Successfully removed orphaned directory"
186
- rescue StandardError => e
187
- LOG.error "Failed to remove orphaned directory: #{e.message}"
188
- raise
189
- end
190
- end
191
- end
175
+ cleanup_orphaned_worktree(worktree_path, worktree_list) if File.directory?(worktree_path)
192
176
 
193
177
  branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
194
178
 
195
179
  if branch_exists
196
- LOG.info "Branch #{branch} already exists, checking for existing worktree"
197
- worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
198
- has_worktree = worktree_list.lines.any? { |line| line.strip == "worktree #{worktree_path}" }
199
-
200
- if has_worktree && File.directory?(worktree_path)
201
- LOG.info "Reusing existing worktree at #{worktree_path}"
202
- else
203
- LOG.info "Creating worktree from existing branch #{branch}"
204
- run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
205
- end
180
+ attach_or_create_worktree_for_branch(repo_path, branch, worktree_path)
206
181
  else
207
182
  LOG.info "Creating new branch #{branch} and worktree"
208
183
  run_cmd("git", "worktree", "add", "-b", branch, worktree_path, base_ref, chdir: repo_path)
@@ -215,6 +190,56 @@ def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path:
215
190
  worktree_path
216
191
  end
217
192
 
193
+ # Remove an orphaned worktree directory (exists on disk but not tracked by git).
194
+ # If the directory is tracked, logs and leaves it alone.
195
+ def cleanup_orphaned_worktree(worktree_path, worktree_list)
196
+ resolved_path = begin
197
+ File.realpath(worktree_path)
198
+ rescue StandardError
199
+ worktree_path
200
+ end
201
+ is_tracked = worktree_list.include?(worktree_path) || worktree_list.include?(resolved_path)
202
+
203
+ if is_tracked
204
+ LOG.info "Worktree directory #{worktree_path} is tracked by git"
205
+ else
206
+ LOG.warn "Orphaned worktree directory found at #{worktree_path}, removing it"
207
+ begin
208
+ FileUtils.rm_rf(worktree_path)
209
+ LOG.info "Successfully removed orphaned directory"
210
+ rescue StandardError => e
211
+ LOG.error "Failed to remove orphaned directory: #{e.message}"
212
+ raise
213
+ end
214
+ end
215
+ end
216
+
217
+ # Attach an existing branch to a worktree, reusing it if already present.
218
+ def attach_or_create_worktree_for_branch(repo_path, branch, worktree_path)
219
+ LOG.info "Branch #{branch} already exists, checking for existing worktree"
220
+ worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
221
+ # Resolve symlinks for comparison (macOS /var/folders vs /private/var/folders)
222
+ resolved_wt = begin
223
+ File.realpath(worktree_path)
224
+ rescue StandardError
225
+ worktree_path
226
+ end
227
+ has_worktree = worktree_list.lines.any? do |line|
228
+ stripped = line.strip
229
+ next false unless stripped.start_with?("worktree ")
230
+
231
+ listed_path = stripped.sub("worktree ", "")
232
+ listed_path == worktree_path || listed_path == resolved_wt
233
+ end
234
+
235
+ if has_worktree && File.directory?(worktree_path)
236
+ LOG.info "Reusing existing worktree at #{worktree_path}"
237
+ else
238
+ LOG.info "Creating worktree from existing branch #{branch}"
239
+ run_cmd("git", "worktree", "add", worktree_path, branch, chdir: repo_path)
240
+ end
241
+ end
242
+
218
243
  # Find an existing worktree for a card by scanning the filesystem.
219
244
  def find_worktree_for_card(card_number, repo_path:)
220
245
  return nil unless card_number
@@ -18,7 +18,9 @@ def load_cli_provider(provider_name)
18
18
  "agent_cli" => raw["binary"],
19
19
  "agent_cli_args" => raw["default_args"],
20
20
  "agent_model_flag" => raw["model_flag"],
21
+ "agent_model" => raw["agent_model"],
21
22
  "agent_effort_flag" => raw["effort_flag"],
23
+ "agent_effort" => raw["agent_effort"],
22
24
  "allowed_models" => raw["models"],
23
25
  "allowed_efforts" => raw["efforts"]
24
26
  }
@@ -28,10 +30,15 @@ def load_cli_provider(provider_name)
28
30
  config["agent_flag"] = raw.key?("agent_flag") ? raw["agent_flag"] : "--agent"
29
31
  # prompt_mode: "stdin" (default) or "flag" — how the prompt is delivered.
30
32
  config["prompt_mode"] = raw["prompt_mode"] || "stdin"
31
- config["prompt_flag"] = raw["prompt_flag"] if raw["prompt_flag"]
32
- # resume_flag: when set, follow-up dispatches use this flag to continue the
33
- # most recent session in the working directory (e.g. "-c" or "--continue").
34
- config["resume_flag"] = raw["resume_flag"] if raw["resume_flag"]
33
+ # Copy optional fields from raw config when present.
34
+ # Each field controls a specific CLI behavior see comments in the template.
35
+ %w[prompt_flag list_models_command resume_flag resume_args session_dir output_last_message_flag
36
+ cwd_flag config_override_flag effort_config_key effort_map].each do |key|
37
+ next unless raw[key]
38
+ next if raw[key].respond_to?(:empty?) && raw[key].empty?
39
+
40
+ config[key] = raw[key]
41
+ end
35
42
  # Compact nil values except agent_flag (which uses nil to mean "don't pass agent name")
36
43
  agent_flag_value = config["agent_flag"]
37
44
  config.compact!
@@ -42,6 +49,30 @@ rescue JSON::ParserError => e
42
49
  {}
43
50
  end
44
51
 
52
+ # Run a CLI provider's list_models_command and return the parsed model list.
53
+ # Returns an array of model hashes on success, or nil on failure.
54
+ # Each model hash contains at least: "model_id" (or "slug"/"model_name"), and optionally "description", etc.
55
+ def list_models_for_provider(provider_name)
56
+ config = load_cli_provider(provider_name)
57
+ return nil if config.empty?
58
+
59
+ command = config["list_models_command"]
60
+ return nil unless command && !command.empty?
61
+
62
+ stdout, stderr, status = Open3.capture3(command)
63
+ unless status.success?
64
+ LOG.warn "list_models_command for '#{provider_name}' failed (exit #{status.exitstatus}): #{stderr.strip}"
65
+ return nil
66
+ end
67
+
68
+ parse_list_models_output(stdout)
69
+ rescue StandardError => e
70
+ LOG.warn "Failed to run list_models_command for '#{provider_name}': #{e.message}"
71
+ nil
72
+ end
73
+
74
+ require_relative "model_parser"
75
+
45
76
  # Resolve CLI config for a project by merging provider defaults with project overrides.
46
77
  # Priority: cli_provider_override > agent-level cli_provider > project-level cli_provider > DEFAULT_PROJECT
47
78
  def resolve_project_cli_config(project_config, cli_provider_override: nil, agent_name: nil)
@@ -435,17 +466,26 @@ end
435
466
  # Check if a prior CLI session exists in the given directory for the specified CLI binary.
436
467
  # This prevents resume attempts when the CLI provider changed (e.g., [cli:grok] in a thread
437
468
  # started by kiro-cli) or when the session was started on a different machine.
438
- def prior_session_exists?(chdir, agent_cli)
469
+ # session_dir: optional centralized session directory (e.g. ~/.codex/sessions) for CLIs
470
+ # that store session state globally rather than per-project.
471
+ def prior_session_exists?(chdir, agent_cli, session_dir: nil)
439
472
  return false unless chdir && agent_cli
440
473
 
441
474
  cli_name = File.basename(agent_cli)
442
475
 
476
+ # Centralized session storage (e.g. Codex stores sessions in ~/.codex/sessions/).
477
+ # Search session files for ones that match the working directory (cwd field in session metadata).
478
+ if session_dir
479
+ expanded_session_dir = File.expand_path(session_dir)
480
+ return centralized_session_matches_cwd?(expanded_session_dir, chdir) if File.directory?(expanded_session_dir)
481
+ end
482
+
443
483
  # Check for CLI-specific session markers:
444
484
  # - grok uses .grok/ directory for session state
445
485
  # - kiro-cli uses .kiro-cli/ or similar
446
486
  # - Generic fallback: check tmp/ for agent logs from this CLI
447
- session_dir = File.join(chdir, ".#{cli_name}")
448
- return true if File.directory?(session_dir)
487
+ session_dir_local = File.join(chdir, ".#{cli_name}")
488
+ return true if File.directory?(session_dir_local)
449
489
 
450
490
  # Fallback: look for recent session logs in tmp/ that suggest this CLI ran here before.
451
491
  # This covers CLIs that don't leave a dotdir but do leave logs via brainiac.
@@ -460,9 +500,60 @@ rescue StandardError
460
500
  false
461
501
  end
462
502
 
503
+ # Check if a centralized session directory has sessions matching the given cwd.
504
+ # Supports CLIs that store sessions as .jsonl files in date-partitioned directories (YYYY/MM/DD/)
505
+ # with a first-line JSON metadata object containing a "payload.cwd" field.
506
+ # Only considers sessions from the last 24 hours as resumable.
507
+ #
508
+ # Performance note: Dir.glob("**/*.jsonl") stats every file in the session directory before
509
+ # filtering by mtime. This is fine for typical usage (days/weeks of sessions) but could slow
510
+ # down if the directory accumulates months of files. The 24-hour mtime cutoff limits actual
511
+ # I/O (only recent files are read), but the glob itself still walks the full tree.
512
+ def centralized_session_matches_cwd?(session_base_dir, target_cwd)
513
+ # Only check recent session files (last 24 hours) to avoid scanning the full history
514
+ cutoff = Time.now - 86_400
515
+ target_cwd_resolved = begin
516
+ File.realpath(target_cwd)
517
+ rescue StandardError
518
+ target_cwd
519
+ end
520
+
521
+ Dir.glob(File.join(session_base_dir, "**", "*.jsonl")).any? do |session_file|
522
+ next unless File.mtime(session_file) > cutoff
523
+
524
+ # Read just the first line to get session_meta with cwd
525
+ first_line = begin
526
+ File.open(session_file, &:readline)
527
+ rescue StandardError
528
+ next
529
+ end
530
+ meta = begin
531
+ JSON.parse(first_line)
532
+ rescue StandardError
533
+ next
534
+ end
535
+ session_cwd = meta.dig("payload", "cwd")
536
+ next unless session_cwd
537
+
538
+ session_cwd_resolved = begin
539
+ File.realpath(session_cwd)
540
+ rescue StandardError
541
+ session_cwd
542
+ end
543
+ session_cwd_resolved == target_cwd_resolved
544
+ end
545
+ rescue StandardError
546
+ false
547
+ end
548
+
463
549
  # Public helper: check if resume is viable for a given project + CLI provider combo.
464
- # Plugins should call this BEFORE building the prompt to decide between
465
- # render_resume_prompt (lean) and render_prompt (full context).
550
+ # Plugins MUST use this method (not check resolved["resume_flag"] directly) to decide
551
+ # whether a session can be resumed. This handles both flag-based resume (e.g. kiro --resume,
552
+ # grok -c) and subcommand-based resume (e.g. codex exec resume --last).
553
+ #
554
+ # Call this BEFORE building the prompt to decide between:
555
+ # - render_resume_prompt (lean, for resumable sessions)
556
+ # - render_prompt with full context (for non-resumable sessions)
466
557
  #
467
558
  # Returns true if the CLI supports resume AND a prior session exists in the working directory.
468
559
  # When this returns false, plugins should use render_prompt with thread history as card_context
@@ -470,17 +561,24 @@ end
470
561
  def resume_viable?(project_config:, cli_provider: nil, agent_name: nil, chdir: nil)
471
562
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
472
563
  chdir ||= resolved["repo_path"]
473
- return false unless resolved["resume_flag"]
564
+ return false unless resolved["resume_flag"] || resolved["resume_args"]
474
565
 
475
- prior_session_exists?(chdir, resolved["agent_cli"])
566
+ prior_session_exists?(chdir, resolved["agent_cli"], session_dir: resolved["session_dir"])
476
567
  end
477
568
 
478
569
  # Determine whether a session resume should actually happen.
479
- # Returns truthy (the resume flag string) if viable, false otherwise.
480
- # Logs a message when resume was requested but isn't possible.
570
+ # Called by run_agent plugins should NOT call this directly; pass `resume: true` to run_agent.
571
+ #
572
+ # Returns:
573
+ # - :resume_args — when the provider uses subcommand-based resume (build_agent_cmd replaces default_args)
574
+ # - String (the resume flag) — when the provider uses flag-based resume (appended to cmd)
575
+ # - false — when resume was not requested or not viable
481
576
  def resolve_resume(resume, resolved, chdir)
482
- return false unless resume && resolved["resume_flag"]
483
- return resolved["resume_flag"] if prior_session_exists?(chdir, resolved["agent_cli"])
577
+ return false unless resume && (resolved["resume_flag"] || resolved["resume_args"])
578
+ if prior_session_exists?(chdir, resolved["agent_cli"], session_dir: resolved["session_dir"])
579
+ # Return :resume_args when the provider uses subcommand-based resume (e.g. Codex exec resume)
580
+ return resolved["resume_args"] ? :resume_args : resolved["resume_flag"]
581
+ end
484
582
 
485
583
  LOG.info "[Dispatch] Resume requested but not viable for #{resolved["agent_cli"]} in #{chdir} — starting fresh session"
486
584
  false
@@ -505,6 +603,10 @@ def intent_skip?(message, agent_name:, source: nil, channel: nil, context: nil)
505
603
  false
506
604
  end
507
605
 
606
+ # Dispatch an agent CLI process. Plugins call this with `resume: true` to request session
607
+ # continuation — the method internally resolves whether to use flag-based resume (appending
608
+ # e.g. --resume or -c) or subcommand-based resume (replacing default_args with resume_args).
609
+ # Plugins should NOT build their own resume logic; pass `resume: true` and let core handle it.
508
610
  def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
509
611
  source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false,
510
612
  message: nil, channel: nil, context: nil, env: {})
@@ -528,14 +630,17 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
528
630
  FileUtils.mkdir_p(File.dirname(log_file))
529
631
 
530
632
  prompt_file = write_agent_prompt_file(prompt, log_name, timestamp)
531
- cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort, prompt_file: prompt_file, resume: should_resume)
633
+ output_file = prepare_output_file(resolved, log_name, timestamp)
634
+
635
+ cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort,
636
+ prompt_file: prompt_file, resume: should_resume,
637
+ output_file: output_file, chdir: chdir)
532
638
  prompt_mode = resolved["prompt_mode"] || "stdin"
533
639
 
534
640
  spawn_env = agent_env_for(agent_name).merge(env)
535
641
 
536
642
  LOG.info "Running #{resolved["agent_cli"]} in #{chdir}, logging to #{log_file}"
537
- LOG.info "Prompt written to #{prompt_file}"
538
- LOG.info "Command: #{cmd.join(" ")}#{" (resuming session)" if should_resume}"
643
+ LOG.info "Prompt: #{prompt_file} | Output: #{output_file || "none"} | Command: #{cmd.join(" ")}#{" (resuming session)" if should_resume}"
539
644
  LOG.info "Injecting #{spawn_env.size} env var(s) for agent #{agent_name}: #{spawn_env.keys.join(", ")}" unless spawn_env.empty?
540
645
 
541
646
  project_key_for_restart = PROJECTS.find { |_k, v| v == project_config }&.first
@@ -555,6 +660,7 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
555
660
  prompt_file: prompt_file, chdir: chdir, source: source,
556
661
  source_context: source_context, project_config: project_config,
557
662
  card_number: card_number, skip_column_move: skip_column_move,
663
+ output_file: output_file,
558
664
  head_before: head_before, status_before: status_before,
559
665
  project_key_for_restart: project_key_for_restart
560
666
  )
@@ -575,32 +681,104 @@ def write_agent_prompt_file(prompt, log_name, timestamp)
575
681
  prompt_file
576
682
  end
577
683
 
684
+ # Generate output file path for structured output capture (--output-last-message).
685
+ # Returns nil if the provider doesn't support it.
686
+ def prepare_output_file(resolved, log_name, timestamp)
687
+ return nil unless resolved["output_last_message_flag"]
688
+
689
+ output_dir = File.join(BRAINIAC_DIR, "tmp", "output")
690
+ FileUtils.mkdir_p(output_dir)
691
+ File.join(output_dir, "agent-#{log_name}-#{timestamp}.md")
692
+ end
693
+
694
+ # Read the structured output file written by the agent CLI (--output-last-message).
695
+ # Returns the file content as a string, or nil if the file doesn't exist or is empty.
696
+ def read_output_file(output_file)
697
+ return nil unless output_file && File.exist?(output_file)
698
+
699
+ content = File.read(output_file).strip
700
+ if content.empty?
701
+ LOG.info "[Output] Output file exists but is empty: #{output_file}"
702
+ return nil
703
+ end
704
+
705
+ LOG.info "[Output] Captured structured output (#{content.bytesize} bytes) from #{output_file}"
706
+ content
707
+ rescue StandardError => e
708
+ LOG.warn "[Output] Failed to read output file #{output_file}: #{e.message}"
709
+ nil
710
+ end
711
+
578
712
  # Build the CLI command array for an agent invocation.
579
713
  # When prompt_file is provided and prompt_mode is "flag", appends the prompt as a CLI argument.
580
- # When resume is true and the provider has a resume_flag, adds it to continue the last session.
581
- def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, prompt_file: nil, resume: false)
714
+ # When resume is truthy and the provider has a resume_flag, adds it to continue the last session.
715
+ # When resume is :resume_args, uses resume_args as the args instead of default_args (subcommand-based resume).
716
+ # When output_file is provided and the provider has output_last_message_flag, appends it.
717
+ # When chdir is provided and the provider has a cwd_flag, appends it so the CLI
718
+ # itself switches to the working directory (e.g. `codex -C /path/to/project`).
719
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
720
+ def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, prompt_file: nil, resume: false, output_file: nil, chdir: nil)
582
721
  cmd = [resolved["agent_cli"]]
722
+ # cwd_flag: pass the working directory as a CLI argument (e.g. -C for Codex CLI).
723
+ # This is added early so it appears before subcommands/args (global option).
724
+ cmd.push(resolved["cwd_flag"], chdir) if resolved["cwd_flag"] && chdir
583
725
  # agent_flag controls how the agent identity is passed. Defaults to "--agent".
584
726
  # Provider configs can set it to a different flag or null to suppress entirely.
585
727
  agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
586
728
  cmd.push(agent_flag, agent_config_name) if agent_flag && agent_config_name
587
- cmd.concat(resolved["agent_cli_args"].split)
729
+ # When resuming via subcommand (resume_args), replace default_args entirely.
730
+ # e.g. "exec --full-auto" becomes "exec resume --last --full-auto"
731
+ args = resume == :resume_args && resolved["resume_args"] ? resolved["resume_args"] : resolved["agent_cli_args"]
732
+ cmd.concat(args.split)
588
733
  # Only pass --model if the model is a valid ID for this provider.
589
734
  # "auto" means "let the CLI choose" — skip passing it unless the provider explicitly maps it.
590
735
  if model && resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty?
591
736
  allowed = resolved["allowed_models"] || {}
592
- # Pass the model if it's a mapped value (e.g. "claude-opus-4.5") or the key itself is mapped
593
- is_known = allowed.value?(model) || allowed.key?(model)
594
- cmd.push(resolved["agent_model_flag"], model) if is_known
737
+ # If the model is a key in allowed_models, use the mapped value (e.g. "auto" -> "o4-mini")
738
+ # This handles cases where different projects use "auto" but each CLI provider maps it differently.
739
+ effective_model = allowed.key?(model) ? allowed[model] : model
740
+ is_known = allowed.value?(effective_model) || allowed.key?(effective_model)
741
+ cmd.push(resolved["agent_model_flag"], effective_model) if is_known
595
742
  end
596
- cmd.push(resolved["agent_effort_flag"], effort) if resolved["agent_effort_flag"] && !resolved["agent_effort_flag"].empty? && effort
597
- # Resume the most recent session in the working directory (for multi-turn CLIs like grok)
598
- cmd.push(resolved["resume_flag"]) if resume && resolved["resume_flag"]
743
+ append_effort_to_cmd(cmd, effort, resolved) if effort
744
+ # Resume via flag (simple append, e.g. grok -c or kiro --resume) only when not using resume_args
745
+ cmd.push(resume) if resume && resume != :resume_args && resume.is_a?(String)
599
746
  # prompt_mode: "flag" passes the prompt file path via the configured prompt_flag (e.g. --prompt-file).
600
747
  cmd.push(resolved["prompt_flag"], prompt_file) if prompt_file && resolved["prompt_mode"] == "flag" && resolved["prompt_flag"]
748
+ # output_last_message_flag: capture the agent's final message to a file (e.g. codex exec -o <path>).
749
+ cmd.push(resolved["output_last_message_flag"], output_file) if output_file && resolved["output_last_message_flag"]
601
750
  cmd
602
751
  end
752
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
753
+
754
+ # Map a Brainiac effort level through the provider's effort_map (if any).
755
+ # Returns the mapped level, or the original level if no mapping exists.
756
+ def map_effort_level(effort, resolved)
757
+ return nil unless effort
758
+
759
+ effort_map = resolved["effort_map"]
760
+ return effort unless effort_map
761
+
762
+ effort_map[effort] || effort
763
+ end
764
+
765
+ # Append effort flags to a command array based on provider config.
766
+ # Handles both dedicated effort flags (--effort high) and config overrides (-c 'key="value"').
767
+ def append_effort_to_cmd(cmd, effort, resolved)
768
+ return unless effort
769
+
770
+ mapped_effort = map_effort_level(effort, resolved)
771
+ return unless mapped_effort
772
+
773
+ if resolved["effort_config_key"]
774
+ flag = resolved["config_override_flag"] || "-c"
775
+ cmd.push(flag, "#{resolved["effort_config_key"]}=\"#{mapped_effort}\"")
776
+ elsif resolved["agent_effort_flag"] && !resolved["agent_effort_flag"].empty?
777
+ cmd.push(resolved["agent_effort_flag"], mapped_effort)
778
+ end
779
+ end
603
780
 
781
+ # Append --model flag if the model is valid for this provider.
604
782
  def handle_agent_completion(**ctx)
605
783
  agent_exit_status = $CHILD_STATUS.exitstatus
606
784
  agent_signaled = $CHILD_STATUS.signaled?
@@ -614,6 +792,9 @@ def handle_agent_completion(**ctx)
614
792
  )
615
793
  end
616
794
 
795
+ # Read structured output if the provider wrote to an output file (--output-last-message).
796
+ output_content = read_output_file(ctx[:output_file])
797
+
617
798
  # Emit lifecycle hook — plugins handle post-session actions (e.g., plugin moves card, appends footer)
618
799
  Brainiac.emit(:agent_completed,
619
800
  card_number: ctx[:card_number] || ctx[:source_context]&.dig(:card_number),
@@ -625,7 +806,12 @@ def handle_agent_completion(**ctx)
625
806
  source_context: ctx[:source_context],
626
807
  project_config: ctx[:project_config],
627
808
  skip_column_move: ctx[:skip_column_move],
628
- prompt_file: ctx[:prompt_file])
809
+ prompt_file: ctx[:prompt_file],
810
+ output_file: ctx[:output_file],
811
+ output_content: output_content)
812
+
813
+ # Clean up the output file after hook emission (content already captured above).
814
+ FileUtils.rm_f(ctx[:output_file]) if ctx[:output_file]
629
815
 
630
816
  qmd_out, qmd_status = Open3.capture2e("qmd", "update")
631
817
  if qmd_status.success?
@@ -656,8 +842,10 @@ def check_brainiac_restart(head_before, status_before, chdir, project_key_for_re
656
842
  end
657
843
  end
658
844
 
659
- def detect_model(project_config, tags: [], text: "", cli_provider_override: nil)
660
- resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider_override)
845
+ def detect_model(project_config, tags: [], text: "", cli_provider_override: nil, agent_name: nil)
846
+ # If no explicit CLI provider override, check if the agent has one configured
847
+ effective_cli_provider = cli_provider_override || agent_cli_provider_for(agent_name)
848
+ resolved = resolve_project_cli_config(project_config, cli_provider_override: effective_cli_provider, agent_name: agent_name)
661
849
  allowed_models = resolved["allowed_models"] || {}
662
850
  return resolved["agent_model"] if allowed_models.empty?
663
851
 
@@ -678,8 +866,9 @@ end
678
866
  # Returns the effort level string (e.g. "high") or nil.
679
867
  # If the requested level isn't supported by the current model, returns the closest
680
868
  # lower level from allowed_efforts.
681
- def detect_effort(project_config, tags: [], text: "", cli_provider_override: nil)
682
- resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider_override)
869
+ def detect_effort(project_config, tags: [], text: "", cli_provider_override: nil, agent_name: nil)
870
+ effective_cli_provider = cli_provider_override || agent_cli_provider_for(agent_name)
871
+ resolved = resolve_project_cli_config(project_config, cli_provider_override: effective_cli_provider, agent_name: agent_name)
683
872
  allowed = resolved["allowed_efforts"] || %w[low medium high xhigh max]
684
873
 
685
874
  # Inline tag: [effort:high] — works in any channel
@@ -113,7 +113,7 @@ def validate_intent_model!(config)
113
113
  end
114
114
 
115
115
  true
116
- rescue Errno::ECONNREFUSED
116
+ rescue Errno::ECONNREFUSED, Socket::ResolutionError, Errno::EADDRNOTAVAIL
117
117
  raise "Ollama is not running at #{config["endpoint"]}. Start it with: ollama serve"
118
118
  rescue Net::OpenTimeout, Net::ReadTimeout
119
119
  LOG.warn "[Intent] Could not validate model (Ollama timed out) — will check at first use"
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Standalone model output parsing — shared between bin/brainiac (CLI) and lib/brainiac/helpers.rb (server).
4
+ # No dependencies beyond Ruby stdlib (json).
5
+
6
+ require "json"
7
+
8
+ # Parse the output of a list_models_command.
9
+ # Supports JSON output with a "models" array, or plain text lines with model names.
10
+ # Normalizes various field names (slug, model_name) to "model_id" for consistency.
11
+ def parse_list_models_output(output)
12
+ return nil if output.nil? || output.strip.empty?
13
+
14
+ # Try parsing the entire output as JSON first (handles well-formed JSON from any CLI)
15
+ begin
16
+ data = JSON.parse(output)
17
+ return normalize_model_list(data["models"]) if data.is_a?(Hash) && data["models"].is_a?(Array)
18
+ return normalize_model_list(data) if data.is_a?(Array)
19
+ rescue JSON::ParserError
20
+ # Not pure JSON — try to extract JSON from mixed output
21
+ end
22
+
23
+ # Some CLIs output non-JSON text before the JSON (like kiro-cli with --format json).
24
+ # Try parsing from any line that starts with "{" — handles both single-line JSON and
25
+ # pretty-printed JSON where "models" is on a subsequent line.
26
+ lines = output.lines
27
+ lines.each_with_index do |line, idx|
28
+ next unless line.strip.start_with?("{")
29
+
30
+ json_candidate = lines[idx..].join
31
+ begin
32
+ data = JSON.parse(json_candidate)
33
+ return normalize_model_list(data["models"]) if data.is_a?(Hash) && data["models"].is_a?(Array)
34
+ rescue JSON::ParserError
35
+ # Try next candidate
36
+ end
37
+ end
38
+
39
+ # Try to find a standalone JSON array starting with [{ (e.g. codex debug models raw output)
40
+ # Intentionally separate loop: {models:[]} has priority over bare arrays
41
+ lines.each_with_index do |line, idx| # rubocop:disable Style/CombinableLoops
42
+ next unless line.strip.start_with?("[{", "[")
43
+
44
+ json_candidate = lines[idx..].join
45
+ begin
46
+ data = JSON.parse(json_candidate)
47
+ return normalize_model_list(data) if data.is_a?(Array) && data.first.is_a?(Hash)
48
+ rescue JSON::ParserError
49
+ # Try next candidate
50
+ end
51
+ end # rubocop:enable Style/CombinableLoops
52
+
53
+ # Fallback: parse plain text output (one model per line, or tabular format)
54
+ parse_list_models_text(output)
55
+ end
56
+
57
+ # Parse plain text model listing (e.g. "* auto 1.00x credits Description here")
58
+ # Lines prefixed with "*" are marked as the default model.
59
+ def parse_list_models_text(output)
60
+ models = []
61
+ output.each_line do |line|
62
+ line = line.strip
63
+ next if line.empty? || line.start_with?("Available") || line.start_with?("Default")
64
+
65
+ is_default = line.start_with?("*")
66
+
67
+ # Match lines like: "* auto 1.00x credits Description" or " claude-sonnet-4.6 1.30x credits Desc"
68
+ if (m = line.match(/^[*\s]*(\S+)\s+(\d+\.\d+x\s+\w+)\s+(.+)$/))
69
+ entry = { "model_id" => m[1], "rate" => m[2].strip, "description" => m[3].strip }
70
+ entry["default"] = true if is_default
71
+ models << entry
72
+ elsif (m = line.match(/^[*\s]*(\S+)\s*$/))
73
+ entry = { "model_id" => m[1] }
74
+ entry["default"] = true if is_default
75
+ models << entry
76
+ end
77
+ end
78
+ models.empty? ? nil : models
79
+ end
80
+
81
+ # Generate a short key from a model_id for use in the models config map.
82
+ # Strips common provider prefixes, lowercases, and normalizes to kebab-case.
83
+ # Examples:
84
+ # "claude-sonnet-4.6" => "sonnet-4-6"
85
+ # "GPT-4o" => "4o"
86
+ # "DeepSeek-V3" => "deepseek-v3"
87
+ def generate_short_model_key(model_id)
88
+ model_id
89
+ .downcase
90
+ .sub(/^claude-/, "")
91
+ .sub(/^grok-/, "")
92
+ .sub(/^gpt-/, "")
93
+ .gsub(/[^a-z0-9]/, "-").squeeze("-")
94
+ .sub(/^-/, "")
95
+ .sub(/-$/, "")
96
+ end
97
+
98
+ # Normalize model hashes to always have "model_id" as the primary identifier.
99
+ # Handles various CLI output formats: "slug" (codex), "model_name" (generic), "model_id" (kiro-cli).
100
+ # Silently skips non-Hash elements (e.g. strings or numbers in a mixed array).
101
+ def normalize_model_list(models)
102
+ models.grep(Hash).map do |m|
103
+ next m if m["model_id"]
104
+
105
+ m = m.dup
106
+ m["model_id"] = m.delete("slug") || m.delete("model_name") || "unknown"
107
+ m
108
+ end
109
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.0.27"
5
+ VERSION = "0.0.28"
6
6
  end
@@ -18,9 +18,7 @@ INFRA_CMDS = %w[kiro-cli-chat ruby-lsp clangd gopls].freeze
18
18
  DISCORD_CONFIG_FILE = File.join(BRAINIAC_DIR, "discord.json")
19
19
 
20
20
  index = ARGV.find { |a| !a.start_with?("--") }&.to_i
21
- unless index
22
- exit
23
- end
21
+ exit unless index
24
22
 
25
23
  def load_discord_guild_id
26
24
  return nil unless File.exist?(DISCORD_CONFIG_FILE)
@@ -0,0 +1,32 @@
1
+ {
2
+ "binary": "codex",
3
+ "default_args": "--full-auto",
4
+ "agent_flag": "--profile",
5
+ "model_flag": "--model",
6
+ "agent_model": "gpt-5.6-terra",
7
+ "effort_flag": null,
8
+ "effort_config_key": "model_reasoning_effort",
9
+ "config_override_flag": "-c",
10
+ "effort_map": {
11
+ "low": "low",
12
+ "medium": "medium",
13
+ "high": "high",
14
+ "xhigh": "xhigh",
15
+ "max": "xhigh"
16
+ },
17
+ "prompt_mode": "stdin",
18
+ "cwd_flag": "-C",
19
+ "resume_flag": null,
20
+ "resume_args": "exec resume --last --full-auto",
21
+ "session_dir": "~/.codex/sessions",
22
+ "output_last_message_flag": "-o",
23
+ "list_models_command": "codex debug models",
24
+ "models": {
25
+ "sol": "gpt-5.6-sol",
26
+ "terra": "gpt-5.6-terra",
27
+ "luna": "gpt-5.6-luna",
28
+ "gpt5.5": "gpt-5.5",
29
+ "auto": "gpt-5.6-terra"
30
+ },
31
+ "efforts": ["low", "medium", "high", "xhigh", "max"]
32
+ }
@@ -7,6 +7,7 @@
7
7
  "prompt_mode": "flag",
8
8
  "prompt_flag": "--prompt-file",
9
9
  "resume_flag": "-c",
10
+ "list_models_command": "grok models",
10
11
  "models": {
11
12
  "build": "grok-build",
12
13
  "composer": "grok-composer-2.5-fast",
@@ -6,6 +6,7 @@
6
6
  "effort_flag": "--effort",
7
7
  "prompt_mode": "stdin",
8
8
  "resume_flag": "--resume",
9
+ "list_models_command": "kiro-cli chat --list-models --format json",
9
10
  "models": {
10
11
  "opus": "claude-opus-4.5",
11
12
  "sonnet": "claude-sonnet-4.6",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brainiac
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.27
4
+ version: 0.0.28
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis
@@ -123,6 +123,7 @@ files:
123
123
  - bin/brainiac
124
124
  - bin/brainiac-completion.bash
125
125
  - brainiac.gemspec
126
+ - docs/resume.md
126
127
  - docs/waybar-config.md
127
128
  - lib/brainiac.rb
128
129
  - lib/brainiac/agents.rb
@@ -134,6 +135,7 @@ files:
134
135
  - lib/brainiac/helpers.rb
135
136
  - lib/brainiac/hooks.rb
136
137
  - lib/brainiac/intent.rb
138
+ - lib/brainiac/model_parser.rb
137
139
  - lib/brainiac/notifications.rb
138
140
  - lib/brainiac/plugins.rb
139
141
  - lib/brainiac/prompts.rb
@@ -159,6 +161,7 @@ files:
159
161
  - skills/brainiac-plugins/SKILL.md
160
162
  - templates/agents.json.example
161
163
  - templates/brainiac.json.example
164
+ - templates/cli-providers/codex.json.example
162
165
  - templates/cli-providers/grok.json.example
163
166
  - templates/cli-providers/kiro.json.example
164
167
  - templates/hooks/pre-commit