brainiac 0.0.31 → 0.0.33

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: ab80207a78e9cf2150459e67eae0d37fd9768a3f6aae774209099bd2b0f7fe93
4
- data.tar.gz: c0af2f5a0348fa8540d575f7124218d803dee7531a6dc68205d516108314ae63
3
+ metadata.gz: aa14f181c9965192fcc2507837be14e29cc9bce6a0273d806dd556eb2bfb72e2
4
+ data.tar.gz: c1d2fcbed4ce125a150d442e4cc3a1d68dfa856c424addf981904b4b88393b70
5
5
  SHA512:
6
- metadata.gz: 83451dba00ca901f85495784fcd143a5ad4fe808ea2921702805804be1bef0f26202435e6057ddfb935cbc3d3f66518eed17878ecad9123034dbd8a33bdbac28
7
- data.tar.gz: 194a1c6bc7e5819cd48f3f74aeb28988e6ebcaf0681cef7ac6168497a0a487ee116ee7c78b2b7bb9a6f750743ce23c9e2d5fdc6f8cb4c6e50e4b05f2397cac55
6
+ metadata.gz: beaf113fb5acf361c73df0da4d93dd65a7a7159397c0b1c559e4b84df9396376148a95a7e09824744ca1d272ef51c6008da334bc906a3bd6ce8d940d0568f43a
7
+ data.tar.gz: 5382b695151def7319fcadfbb51ae84f6f4401b43b9de92ecd51e5c17b20a8e05723cd9943dfba4229ca70a7097f317c70f44b973e9d815f9815ee994b8414c9
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- brainiac (0.0.31)
4
+ brainiac (0.0.33)
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.31)
87
+ brainiac (0.0.33)
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
@@ -177,6 +177,48 @@ module BeltConfig
177
177
  def ephemeral_env_for_epic(epic_number)
178
178
  "epic-#{epic_number}"
179
179
  end
180
+
181
+ # Find an active epic ephemeral env by its tracked epic branch.
182
+ #
183
+ # Epic envs are keyed by name (e.g. "epic-fp-ux") rather than a card number,
184
+ # and carry `epic_branch` / `epic_pr` fields. This scans the tracking file
185
+ # for an active entry whose `epic_branch` matches.
186
+ #
187
+ # @param branch [String] Head branch name (e.g. "epic/feature-parity-...")
188
+ # @return [Array(String, Hash), nil] [env_name, entry] or nil
189
+ def epic_env_for_branch(branch)
190
+ return nil if branch.nil? || branch.empty?
191
+
192
+ find_active_epic_env { |entry| entry["epic_branch"] == branch }
193
+ end
194
+
195
+ # Find an active epic ephemeral env by its tracked epic PR URL.
196
+ #
197
+ # @param pr_url [String] Pull request html_url
198
+ # @return [Array(String, Hash), nil] [env_name, entry] or nil
199
+ def epic_env_for_pr(pr_url)
200
+ return nil if pr_url.nil? || pr_url.empty?
201
+
202
+ find_active_epic_env { |entry| entry["epic_pr"] == pr_url }
203
+ end
204
+
205
+ private
206
+
207
+ # Scan ephemeral_envs.json for the first active epic entry matching the block.
208
+ # @yield [entry] each active entry hash
209
+ # @return [Array(String, Hash), nil] [env_name, entry] or nil
210
+ def find_active_epic_env
211
+ state_file = File.join(BRAINIAC_DIR, "ephemeral_envs.json")
212
+ return nil unless File.exist?(state_file)
213
+
214
+ state = JSON.parse(File.read(state_file))
215
+ # Returns [name, entry] for the first match, or nil.
216
+ state.find do |_name, entry|
217
+ entry.is_a?(Hash) && entry["status"] == "active" && entry["epic_branch"] && yield(entry)
218
+ end
219
+ rescue StandardError
220
+ nil
221
+ end
180
222
  end
181
223
  end
182
224
 
@@ -32,8 +32,10 @@ def load_cli_provider(provider_name)
32
32
  config["prompt_mode"] = raw["prompt_mode"] || "stdin"
33
33
  # Copy optional fields from raw config when present.
34
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|
35
+ %w[prompt_flag list_models_command resume_flag resume_args resume_id_flag new_session_id_flag
36
+ session_list_command session_id_field session_directory_field session_updated_field
37
+ session_list_path session_dir output_last_message_flag cwd_flag config_override_flag
38
+ effort_config_key effort_map title_flag].each do |key|
37
39
  next unless raw[key]
38
40
  next if raw[key].respond_to?(:empty?) && raw[key].empty?
39
41
 
@@ -247,6 +249,67 @@ def find_work_item_by_card(card_internal_id)
247
249
  nil
248
250
  end
249
251
 
252
+ # Find a work item by worktree path. Returns [work_item_id, info] or nil.
253
+ # Compares realpaths so symlink worktrees still match.
254
+ def find_work_item_by_worktree(worktree)
255
+ return nil unless worktree
256
+
257
+ target = path_for_compare(worktree)
258
+ return nil unless target
259
+
260
+ map = load_work_item_map
261
+ map.each do |work_item_id, info|
262
+ next unless info.is_a?(Hash) && info["worktree"]
263
+ next unless path_for_compare(info["worktree"]) == target
264
+
265
+ return [work_item_id, info]
266
+ end
267
+ nil
268
+ end
269
+
270
+ def path_for_compare(path)
271
+ return nil unless path
272
+
273
+ File.realpath(path)
274
+ rescue StandardError
275
+ File.expand_path(path.to_s)
276
+ end
277
+
278
+ # Key used in work_item["cli_sessions"] — the CLI binary basename (e.g. "opencode").
279
+ def cli_session_key(agent_cli)
280
+ return nil if agent_cli.nil? || agent_cli.to_s.empty?
281
+
282
+ File.basename(agent_cli.to_s)
283
+ end
284
+
285
+ # Stored OpenCode/CLI session id for this work item + CLI, or nil.
286
+ def cli_session_id_for(agent_cli:, worktree: nil, work_item_id: nil)
287
+ key = cli_session_key(agent_cli)
288
+ return nil unless key
289
+
290
+ info = find_work_item_by_id(work_item_id) if work_item_id
291
+ info ||= find_work_item_by_worktree(worktree)&.last
292
+ info&.dig("cli_sessions", key)
293
+ end
294
+
295
+ # Persist a CLI session id on the work item, keyed by CLI binary name.
296
+ # Returns true if stored, false if no matching work item.
297
+ def update_work_item_cli_session(agent_cli:, session_id:, worktree: nil, work_item_id: nil) # rubocop:disable Naming/PredicateMethod
298
+ key = cli_session_key(agent_cli)
299
+ return false unless key && session_id && !session_id.to_s.empty?
300
+
301
+ map = load_work_item_map
302
+ target_id = work_item_id
303
+ target_id ||= find_work_item_by_worktree(worktree)&.first
304
+ return false unless target_id && map[target_id]
305
+
306
+ map[target_id]["cli_sessions"] ||= {}
307
+ map[target_id]["cli_sessions"][key] = session_id.to_s
308
+ save_work_item_map(map)
309
+ LOG.info "[WorkItem] Stored #{key} session #{session_id} on #{target_id}"
310
+ true
311
+ end
312
+
250
313
  # Register a new work item or update an existing one.
251
314
  # Returns the work item ID.
252
315
  def register_work_item(branch:, worktree: nil, project: nil, agent: nil, source: nil, source_data: {})
@@ -561,11 +624,24 @@ end
561
624
  def resume_viable?(project_config:, cli_provider: nil, agent_name: nil, chdir: nil)
562
625
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
563
626
  chdir ||= resolved["repo_path"]
627
+ return stored_cli_session_viable?(resolved, chdir) if resolved["resume_id_flag"]
564
628
  return false unless resolved["resume_flag"] || resolved["resume_args"]
565
629
 
566
630
  prior_session_exists?(chdir, resolved["agent_cli"], session_dir: resolved["session_dir"])
567
631
  end
568
632
 
633
+ # True when this CLI resumes by session id AND the work item has a still-existing id.
634
+ def stored_cli_session_viable?(resolved, chdir)
635
+ session_id = cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir)
636
+ return false if session_id.to_s.empty?
637
+ return true unless resolved["session_list_command"]
638
+
639
+ sessions = list_cli_sessions(resolved["session_list_command"], **session_list_fields(resolved))
640
+ return false unless sessions
641
+
642
+ sessions.any? { |s| s["id"] == session_id }
643
+ end
644
+
569
645
  # Determine whether a session resume should actually happen.
570
646
  # Called by run_agent — plugins should NOT call this directly; pass `resume: true` to run_agent.
571
647
  #
@@ -573,8 +649,19 @@ end
573
649
  # - :resume_args — when the provider uses subcommand-based resume (build_agent_cmd replaces default_args)
574
650
  # - String (the resume flag) — when the provider uses flag-based resume (appended to cmd)
575
651
  # - false — when resume was not requested or not viable
576
- def resolve_resume(resume, resolved, chdir)
577
- return false unless resume && (resolved["resume_flag"] || resolved["resume_args"])
652
+ def resolve_resume(resume, resolved, chdir, session_id: nil)
653
+ return false unless resume
654
+
655
+ if resolved["resume_id_flag"]
656
+ id = session_id.to_s.empty? ? cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir) : session_id
657
+ if id.to_s.empty?
658
+ LOG.info "[Dispatch] Resume requested but no stored #{resolved["agent_cli"]} session for #{chdir} — starting fresh"
659
+ return false
660
+ end
661
+ return { "flag" => resolved["resume_id_flag"], "id" => id }
662
+ end
663
+
664
+ return false unless resolved["resume_flag"] || resolved["resume_args"]
578
665
  if prior_session_exists?(chdir, resolved["agent_cli"], session_dir: resolved["session_dir"])
579
666
  # Return :resume_args when the provider uses subcommand-based resume (e.g. Codex exec resume)
580
667
  return resolved["resume_args"] ? :resume_args : resolved["resume_flag"]
@@ -584,6 +671,16 @@ def resolve_resume(resume, resolved, chdir)
584
671
  false
585
672
  end
586
673
 
674
+ # Some CLIs (e.g. grok --session-id) let the caller name a new session up front.
675
+ # Mint a UUID only on the first run, when we are not resuming an existing id.
676
+ def mint_cli_session_id(resolved, stored_session:, resuming:)
677
+ return nil if resuming
678
+ return nil unless resolved["new_session_id_flag"]
679
+ return nil unless stored_session.to_s.empty?
680
+
681
+ SecureRandom.uuid
682
+ end
683
+
587
684
  # Check if intent detection says to skip dispatching the agent.
588
685
  # Returns true if the message should be skipped, false otherwise.
589
686
  # Only runs if a raw message is provided, an agent is named, and intent is enabled.
@@ -619,8 +716,12 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
619
716
  effort ||= resolved["agent_effort"]
620
717
  agent_config_name = agent_name&.downcase&.gsub(/[^a-z0-9-]/, "-")
621
718
 
719
+ stored_session = cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir)
720
+ work_item_id = find_work_item_by_worktree(chdir)&.first
721
+
622
722
  # Auto-resume: only if the provider supports it AND a prior session exists for this CLI here.
623
- should_resume = resolve_resume(resume, resolved, chdir)
723
+ should_resume = resolve_resume(resume, resolved, chdir, session_id: stored_session)
724
+ minted_session_id = mint_cli_session_id(resolved, stored_session: stored_session, resuming: should_resume)
624
725
 
625
726
  # Pre-dispatch hook — plugins can prep the working directory (e.g., copy config files, clean up)
626
727
  Brainiac.emit(:pre_dispatch, chdir: chdir, project_config: project_config, agent_name: agent_name)
@@ -634,14 +735,15 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
634
735
 
635
736
  cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort,
636
737
  prompt_file: prompt_file, resume: should_resume,
637
- output_file: output_file, chdir: chdir)
738
+ output_file: output_file, chdir: chdir, title: work_item_id,
739
+ new_session_id: minted_session_id)
638
740
  prompt_mode = resolved["prompt_mode"] || "stdin"
639
741
 
640
742
  spawn_env = agent_env_for(agent_name).merge(env)
641
743
 
642
- LOG.info "Running #{resolved["agent_cli"]} in #{chdir}, logging to #{log_file}"
643
- LOG.info "Prompt: #{prompt_file} | Output: #{output_file || "none"} | Command: #{cmd.join(" ")}#{" (resuming session)" if should_resume}"
644
- LOG.info "Injecting #{spawn_env.size} env var(s) for agent #{agent_name}: #{spawn_env.keys.join(", ")}" unless spawn_env.empty?
744
+ log_agent_launch(resolved: resolved, chdir: chdir, log_file: log_file, prompt_file: prompt_file,
745
+ output_file: output_file, cmd: cmd, should_resume: should_resume,
746
+ spawn_env: spawn_env, agent_name: agent_name)
645
747
 
646
748
  project_key_for_restart = PROJECTS.find { |_k, v| v == project_config }&.first
647
749
  head_before, status_before = capture_git_state(chdir) if project_key_for_restart == "brainiac"
@@ -652,19 +754,16 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
652
754
  out: [log_file, "w"],
653
755
  err: %i[child out])
654
756
 
655
- Thread.new do
656
- Process.wait(pid)
657
- handle_agent_completion(
658
- pid: pid, agent_cli: resolved["agent_cli"], agent_config_name: agent_config_name,
659
- agent_name: agent_name, log_file: log_file, log_name: log_name,
660
- prompt_file: prompt_file, chdir: chdir, source: source,
661
- source_context: source_context, project_config: project_config,
662
- card_number: card_number, skip_column_move: skip_column_move,
663
- output_file: output_file,
664
- head_before: head_before, status_before: status_before,
665
- project_key_for_restart: project_key_for_restart
666
- )
667
- end
757
+ spawn_completion_watcher(
758
+ pid: pid, agent_cli: resolved["agent_cli"], agent_config_name: agent_config_name,
759
+ agent_name: agent_name, log_file: log_file, log_name: log_name,
760
+ prompt_file: prompt_file, chdir: chdir, source: source,
761
+ source_context: source_context, project_config: project_config,
762
+ card_number: card_number, skip_column_move: skip_column_move,
763
+ output_file: output_file, resolved: resolved, minted_session_id: minted_session_id,
764
+ head_before: head_before, status_before: status_before,
765
+ project_key_for_restart: project_key_for_restart
766
+ )
668
767
 
669
768
  LOG.info "#{resolved["agent_cli"]} started (pid: #{pid}, agent: #{agent_config_name || "default"}, " \
670
769
  "model: #{model || "default"}), tail -f #{log_file}"
@@ -672,6 +771,23 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
672
771
  [pid, log_file]
673
772
  end
674
773
 
774
+ # Spawn a background thread that waits for the agent process to finish and runs completion handling.
775
+ def spawn_completion_watcher(**ctx)
776
+ Thread.new do
777
+ Process.wait(ctx[:pid])
778
+ handle_agent_completion(**ctx)
779
+ end
780
+ end
781
+
782
+ # Log the details of an agent launch (command, prompt/output files, injected env).
783
+ def log_agent_launch(resolved:, chdir:, log_file:, prompt_file:, output_file:, cmd:, should_resume:, spawn_env:, agent_name:)
784
+ LOG.info "Running #{resolved["agent_cli"]} in #{chdir}, logging to #{log_file}"
785
+ LOG.info "Prompt: #{prompt_file} | Output: #{output_file || "none"} | Command: #{cmd.join(" ")}#{" (resuming session)" if should_resume}"
786
+ return if spawn_env.empty?
787
+
788
+ LOG.info "Injecting #{spawn_env.size} env var(s) for agent #{agent_name}: #{spawn_env.keys.join(", ")}"
789
+ end
790
+
675
791
  # Write agent prompt to a temp file, return path.
676
792
  def write_agent_prompt_file(prompt, log_name, timestamp)
677
793
  prompt_dir = File.join(BRAINIAC_DIR, "tmp")
@@ -717,7 +833,8 @@ end
717
833
  # When chdir is provided and the provider has a cwd_flag, appends it so the CLI
718
834
  # itself switches to the working directory (e.g. `codex -C /path/to/project`).
719
835
  # 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)
836
+ def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, prompt_file: nil, resume: false,
837
+ output_file: nil, chdir: nil, title: nil, new_session_id: nil)
721
838
  cmd = [resolved["agent_cli"]]
722
839
  # cwd_flag: pass the working directory as a CLI argument (e.g. -C for Codex CLI).
723
840
  # This is added early so it appears before subcommands/args (global option).
@@ -730,25 +847,44 @@ def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, p
730
847
  # e.g. "exec --full-auto" becomes "exec resume --last --full-auto"
731
848
  args = resume == :resume_args && resolved["resume_args"] ? resolved["resume_args"] : resolved["agent_cli_args"]
732
849
  cmd.concat(args.split)
733
- # Only pass --model if the model is a valid ID for this provider.
734
- # "auto" means "let the CLI choose" — skip passing it unless the provider explicitly maps it.
735
- if model && resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty?
736
- allowed = resolved["allowed_models"] || {}
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
742
- end
850
+ append_model_to_cmd(cmd, model, resolved)
743
851
  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)
852
+ cmd.push(resolved["title_flag"], title) if title && resolved["title_flag"]
853
+ if new_session_id && resolved["new_session_id_flag"] && !(resume.is_a?(Hash) || resume.is_a?(String) || resume == :resume_args)
854
+ cmd.push(resolved["new_session_id_flag"], new_session_id)
855
+ end
856
+ append_resume_to_cmd(cmd, resume)
746
857
  # prompt_mode: "flag" passes the prompt file path via the configured prompt_flag (e.g. --prompt-file).
747
858
  cmd.push(resolved["prompt_flag"], prompt_file) if prompt_file && resolved["prompt_mode"] == "flag" && resolved["prompt_flag"]
748
859
  # output_last_message_flag: capture the agent's final message to a file (e.g. codex exec -o <path>).
749
860
  cmd.push(resolved["output_last_message_flag"], output_file) if output_file && resolved["output_last_message_flag"]
750
861
  cmd
751
862
  end
863
+
864
+ # Append the --model flag when a valid model ID is resolved for this provider.
865
+ # "auto" means "let the CLI choose" — skip passing it unless the provider maps it.
866
+ def append_model_to_cmd(cmd, model, resolved)
867
+ return unless model && resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty?
868
+
869
+ allowed = resolved["allowed_models"] || {}
870
+ # If the model is a key in allowed_models, use the mapped value (e.g. "auto" -> "o4-mini").
871
+ # This handles cases where different projects use "auto" but each CLI provider maps it differently.
872
+ effective_model = allowed.key?(model) ? allowed[model] : model
873
+ is_known = allowed.value?(effective_model) || allowed.key?(effective_model)
874
+ cmd.push(resolved["agent_model_flag"], effective_model) if is_known
875
+ end
876
+
877
+ # Resume via --session <id> (OpenCode), a bare flag (grok -c), or resume_args (already applied).
878
+ def append_resume_to_cmd(cmd, resume)
879
+ case resume
880
+ when Hash
881
+ flag = resume["flag"] || resume[:flag]
882
+ id = resume["id"] || resume[:id]
883
+ cmd.push(flag, id) if flag && id
884
+ when String
885
+ cmd.push(resume)
886
+ end
887
+ end
752
888
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
753
889
 
754
890
  # Map a Brainiac effort level through the provider's effort_map (if any).
@@ -778,23 +914,130 @@ def append_effort_to_cmd(cmd, effort, resolved)
778
914
  end
779
915
  end
780
916
 
781
- # Append --model flag if the model is valid for this provider.
917
+ # Parse a CLI session list as JSON, ignoring non-JSON prefixes (e.g. mise).
918
+ # Normalizes OpenCode `{id, directory, updated}` and kiro `{sessionId, cwd, updatedAt}`
919
+ # (including kiro's `[{cwd, sessions: [...]}]` envelopes) into a flat array of
920
+ # `{ "id", "directory", "updated" }` hashes. Provider JSON can override field names.
921
+ def parse_session_list_output(output, id_field: nil, directory_field: nil, updated_field: nil, list_path: nil)
922
+ return nil if output.nil? || output.strip.empty?
923
+
924
+ json_start = output.index("[") || output.index("{")
925
+ return nil unless json_start
926
+
927
+ data = JSON.parse(output[json_start..])
928
+ rows = data.is_a?(Array) ? data : Array(data["sessions"] || data["data"])
929
+ return nil unless rows.is_a?(Array)
930
+
931
+ flatten_session_rows(rows, id_field: id_field, directory_field: directory_field,
932
+ updated_field: updated_field, list_path: list_path)
933
+ rescue JSON::ParserError
934
+ nil
935
+ end
936
+
937
+ def flatten_session_rows(rows, id_field: nil, directory_field: nil, updated_field: nil, list_path: nil)
938
+ nested_key = list_path.to_s.empty? ? "sessions" : list_path
939
+ fields = { id_field: id_field, directory_field: directory_field, updated_field: updated_field }
940
+ rows.flat_map do |row|
941
+ next [] unless row.is_a?(Hash)
942
+
943
+ nested = row[nested_key]
944
+ if nested.is_a?(Array)
945
+ parent_dir = session_value(row, directory_field, %w[directory dir cwd])
946
+ nested.filter_map { |item| normalize_session_row(item, parent_dir: parent_dir, **fields) }
947
+ else
948
+ [normalize_session_row(row, **fields)].compact
949
+ end
950
+ end
951
+ end
952
+
953
+ def normalize_session_row(row, id_field: nil, directory_field: nil, updated_field: nil, parent_dir: nil)
954
+ return nil unless row.is_a?(Hash)
955
+
956
+ id = session_value(row, id_field, %w[id sessionId session_id])
957
+ return nil if id.to_s.empty?
958
+
959
+ {
960
+ "id" => id.to_s,
961
+ "directory" => session_value(row, directory_field, %w[directory dir cwd]) || parent_dir,
962
+ "updated" => session_value(row, updated_field, %w[updated time_updated updatedAt updated_at]) || 0
963
+ }
964
+ end
965
+
966
+ def session_value(row, explicit_field, fallbacks)
967
+ return row[explicit_field] if explicit_field && !explicit_field.to_s.empty? && row.key?(explicit_field)
968
+
969
+ fallbacks.each { |key| return row[key] if row.key?(key) }
970
+ nil
971
+ end
972
+
973
+ def session_list_fields(resolved)
974
+ {
975
+ id_field: resolved["session_id_field"],
976
+ directory_field: resolved["session_directory_field"],
977
+ updated_field: resolved["session_updated_field"],
978
+ list_path: resolved["session_list_path"]
979
+ }
980
+ end
981
+
982
+ def list_cli_sessions(command, **fields)
983
+ stdout, stderr, status = Open3.capture3(command)
984
+ unless status.success?
985
+ LOG.warn "[Session] session_list_command failed (exit #{status.exitstatus}): #{stderr.strip}"
986
+ return nil
987
+ end
988
+
989
+ parse_session_list_output(stdout, **fields)
990
+ end
991
+
992
+ # Most recently updated session whose directory matches chdir.
993
+ # If no session has a directory (CLI already scoped the list to cwd), pick the newest overall.
994
+ def select_session_id_for_directory(sessions, chdir)
995
+ return nil if sessions.nil? || sessions.empty?
996
+
997
+ with_ids = sessions.select { |session| session.is_a?(Hash) && !session["id"].to_s.empty? }
998
+ return nil if with_ids.empty?
999
+
1000
+ if chdir
1001
+ target = path_for_compare(chdir)
1002
+ matching = with_ids.select { |session| session["directory"] && path_for_compare(session["directory"]) == target }
1003
+ with_ids = matching unless matching.empty?
1004
+ end
1005
+
1006
+ newest = with_ids.max_by { |session| session["updated"] || 0 }
1007
+ newest && newest["id"]
1008
+ end
1009
+
1010
+ # After a run, remember the CLI session id on the work item so later dispatches
1011
+ # can pass --session/--resume-id <id> instead of continuing whichever session was last globally.
1012
+ def capture_cli_session_id(resolved:, chdir:, minted_session_id: nil)
1013
+ if minted_session_id
1014
+ update_work_item_cli_session(agent_cli: resolved["agent_cli"], session_id: minted_session_id, worktree: chdir)
1015
+ return
1016
+ end
1017
+ return unless resolved["session_list_command"] && resolved["resume_id_flag"]
1018
+
1019
+ sessions = list_cli_sessions(resolved["session_list_command"], **session_list_fields(resolved))
1020
+ session_id = select_session_id_for_directory(sessions, chdir)
1021
+ unless session_id
1022
+ LOG.info "[Session] No CLI session found for #{chdir}"
1023
+ return
1024
+ end
1025
+
1026
+ update_work_item_cli_session(agent_cli: resolved["agent_cli"], session_id: session_id, worktree: chdir)
1027
+ end
1028
+
782
1029
  def handle_agent_completion(**ctx)
783
1030
  agent_exit_status = $CHILD_STATUS.exitstatus
784
1031
  agent_signaled = $CHILD_STATUS.signaled?
785
1032
  LOG.info "#{ctx[:agent_cli]} finished (pid: #{ctx[:pid]}, exit: #{agent_exit_status})"
786
1033
 
787
- if ctx[:source] && agent_exit_status && agent_exit_status != 0 && !agent_signaled
788
- notify_agent_crash(
789
- exit_status: agent_exit_status, log_file: ctx[:log_file],
790
- agent_name: ctx[:agent_name], source: ctx[:source], source_context: ctx[:source_context],
791
- project_config: ctx[:project_config]
792
- )
793
- end
1034
+ notify_crash_if_needed(ctx, agent_exit_status, agent_signaled)
794
1035
 
795
1036
  # Read structured output if the provider wrote to an output file (--output-last-message).
796
1037
  output_content = read_output_file(ctx[:output_file])
797
1038
 
1039
+ capture_session_id_if_possible(ctx)
1040
+
798
1041
  # Emit lifecycle hook — plugins handle post-session actions (e.g., plugin moves card, appends footer)
799
1042
  Brainiac.emit(:agent_completed,
800
1043
  card_number: ctx[:card_number] || ctx[:source_context]&.dig(:card_number),
@@ -813,22 +1056,49 @@ def handle_agent_completion(**ctx)
813
1056
  # Clean up the output file after hook emission (content already captured above).
814
1057
  FileUtils.rm_f(ctx[:output_file]) if ctx[:output_file]
815
1058
 
1059
+ run_qmd_update(ctx[:agent_config_name])
1060
+ log_skill_candidate(ctx[:log_file])
1061
+
1062
+ brain_push(message: "#{ctx[:agent_config_name] || "agent"}: #{ctx[:log_name]}")
1063
+ # check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
1064
+ end
1065
+
1066
+ # Capture the CLI session id after a run, if the provider and working dir are known.
1067
+ def capture_session_id_if_possible(ctx)
1068
+ return unless ctx[:resolved] && ctx[:chdir]
1069
+
1070
+ capture_cli_session_id(resolved: ctx[:resolved], chdir: ctx[:chdir], minted_session_id: ctx[:minted_session_id])
1071
+ end
1072
+
1073
+ # Notify plugins of a crashed agent session (non-zero exit, not signaled).
1074
+ def notify_crash_if_needed(ctx, agent_exit_status, agent_signaled)
1075
+ return unless ctx[:source] && agent_exit_status && agent_exit_status != 0 && !agent_signaled
1076
+
1077
+ notify_agent_crash(
1078
+ exit_status: agent_exit_status, log_file: ctx[:log_file],
1079
+ agent_name: ctx[:agent_name], source: ctx[:source], source_context: ctx[:source_context],
1080
+ project_config: ctx[:project_config]
1081
+ )
1082
+ end
1083
+
1084
+ # Run `qmd update` to reindex the brain after a session, logging the outcome.
1085
+ def run_qmd_update(agent_config_name)
816
1086
  qmd_out, qmd_status = Open3.capture2e("qmd", "update")
817
1087
  if qmd_status.success?
818
- LOG.info "[Brain] qmd update completed after #{ctx[:agent_config_name] || "agent"} session"
1088
+ LOG.info "[Brain] qmd update completed after #{agent_config_name || "agent"} session"
819
1089
  else
820
1090
  LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}"
821
1091
  end
1092
+ end
822
1093
 
823
- skill_candidate = detect_skill_candidate(ctx[:log_file])
824
- if skill_candidate[:extract]
825
- LOG.info "[Skills] Session qualifies for skill extraction " \
826
- "(#{skill_candidate[:tool_calls]} tool calls, #{skill_candidate[:error_patterns]} error patterns) " \
827
- "— agent was nudged via reflection prompt"
828
- end
1094
+ # Log whether the session qualifies for skill extraction (agent nudged via reflection prompt).
1095
+ def log_skill_candidate(log_file)
1096
+ skill_candidate = detect_skill_candidate(log_file)
1097
+ return unless skill_candidate[:extract]
829
1098
 
830
- brain_push(message: "#{ctx[:agent_config_name] || "agent"}: #{ctx[:log_name]}")
831
- # check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
1099
+ LOG.info "[Skills] Session qualifies for skill extraction " \
1100
+ "(#{skill_candidate[:tool_calls]} tool calls, #{skill_candidate[:error_patterns]} error patterns) " \
1101
+ "— agent was nudged via reflection prompt"
832
1102
  end
833
1103
 
834
1104
  def check_brainiac_restart(head_before, status_before, chdir, project_key_for_restart, agent_config_name)
@@ -47,6 +47,54 @@ def format_recent_sessions
47
47
  end
48
48
  end
49
49
 
50
+ # Whether a log file path is safe to serve over /api/logs.
51
+ #
52
+ # Agent sessions log into `<repo>/tmp` for the registered project, but most
53
+ # real work (Fizzy cards, PR reviews, Discord agents) runs in a *worktree*
54
+ # sibling directory named `<repo>--<suffix>` (e.g. `feature_parity--fizzy-1377-…`),
55
+ # which is NOT under the registered repo_path. We therefore allow a log file
56
+ # when it lives under any registered repo's `tmp/`, under any worktree sibling
57
+ # of a registered repo (`<repo>--*/tmp/`), or under Brainiac's own `tmp/`.
58
+ #
59
+ # The worktree check is purely path-based (no filesystem globbing): the tmp
60
+ # dir's grandparent directory must be a registered repo and the worktree name
61
+ # must start with `<repo_basename>--`. This is cheap enough for the 2s log
62
+ # poller and still matches even if the worktree was removed after the log was
63
+ # written.
64
+ def log_file_allowed?(log_file)
65
+ return true if log_allowed_repo_dirs.any? { |dir| log_file.start_with?(dir) }
66
+
67
+ log_file_in_worktree_tmp?(log_file)
68
+ end
69
+
70
+ def log_allowed_repo_dirs
71
+ dirs = PROJECTS.values.filter_map do |project|
72
+ repo_path = project["repo_path"]
73
+ (File.join(repo_path, "tmp") + File::SEPARATOR) if repo_path && !repo_path.empty?
74
+ end
75
+ dirs << (File.join(BRAINIAC_DIR, "tmp") + File::SEPARATOR)
76
+ dirs
77
+ end
78
+
79
+ # True when `log_file` sits in a `tmp/` dir of a worktree sibling of a
80
+ # registered repo: `<parent>/<repo_basename>--<suffix>/tmp/...`.
81
+ def log_file_in_worktree_tmp?(log_file)
82
+ tmp_dir = File.dirname(log_file)
83
+ return false unless File.basename(tmp_dir) == "tmp"
84
+
85
+ worktree = File.dirname(tmp_dir)
86
+ worktree_name = File.basename(worktree)
87
+ worktree_parent = File.dirname(worktree)
88
+
89
+ PROJECTS.values.any? do |project|
90
+ repo_path = project["repo_path"]
91
+ next false unless repo_path && !repo_path.empty?
92
+
93
+ File.dirname(repo_path) == worktree_parent &&
94
+ worktree_name.start_with?("#{File.basename(repo_path)}--")
95
+ end
96
+ end
97
+
50
98
  def kill_child_process(target_pid)
51
99
  Process.kill("TERM", target_pid)
52
100
  Thread.new do
@@ -255,9 +303,7 @@ get "/api/logs" do
255
303
  halt 400, "Invalid path" if log_file.include?("..") || !log_file.start_with?("/")
256
304
  halt 404, "File not found" unless File.exist?(log_file)
257
305
 
258
- allowed = PROJECTS.values.map { |p| File.join(p["repo_path"], "tmp") }
259
- allowed << File.join(BRAINIAC_DIR, "tmp")
260
- halt 403, "Forbidden" unless allowed.any? { |dir| log_file.start_with?(dir) }
306
+ halt 403, "Forbidden" unless log_file_allowed?(log_file)
261
307
 
262
308
  all_lines = File.readlines(log_file).last(lines)
263
309
  all_lines.join.gsub(/\e\[[\d;]*[a-zA-Z]/, "").gsub(/\e\[\?[\d;]*[a-zA-Z]/, "")
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.0.31"
5
+ VERSION = "0.0.33"
6
6
  end
@@ -0,0 +1,19 @@
1
+ {
2
+ "binary": "opencode",
3
+ "default_args": "run --auto",
4
+ "agent_flag": null,
5
+ "model_flag": "--model",
6
+ "prompt_mode": "stdin",
7
+ "resume_id_flag": "--session",
8
+ "session_list_command": "opencode session list --format json",
9
+ "title_flag": "--title",
10
+ "list_models_command": "opencode models ollama",
11
+ "models": {
12
+ "qwen": "ollama/qwen3.6:latest",
13
+ "qwen36": "ollama/qwen3.6:latest",
14
+ "local": "ollama/qwen3.6:latest",
15
+ "gemma": "ollama/gemma3:12b",
16
+ "fast": "ollama/qwen3:4b",
17
+ "auto": "ollama/qwen3.6:latest"
18
+ }
19
+ }
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.31
4
+ version: 0.0.33
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis
@@ -166,6 +166,7 @@ files:
166
166
  - templates/cli-providers/gemini.json.example
167
167
  - templates/cli-providers/grok.json.example
168
168
  - templates/cli-providers/kiro.json.example
169
+ - templates/cli-providers/opencode.json.example
169
170
  - templates/hooks/pre-commit
170
171
  - templates/plugins.json.example
171
172
  - templates/roles/code-reviewer.md.example