brainiac 0.0.2 → 0.0.4

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.
data/lib/brainiac/cron.rb CHANGED
@@ -542,7 +542,9 @@ def execute_cron_job(job)
542
542
  FileUtils.mkdir_p(File.dirname(log_file))
543
543
 
544
544
  prompt_file = write_cron_prompt_file(job, prompt_data[:full_prompt], timestamp)
545
- cmd = build_cron_agent_cmd(job, project)
545
+ resolved = resolve_project_cli_config(project, agent_name: agent_name)
546
+ cmd = build_cron_agent_cmd(job, project, prompt_file: prompt_file)
547
+ prompt_mode = resolved["prompt_mode"] || "stdin"
546
548
 
547
549
  LOG.info "[Cron] Dispatching job #{job[:id]} with #{agent_name}, tail -f #{log_file}"
548
550
 
@@ -551,7 +553,7 @@ def execute_cron_job(job)
551
553
 
552
554
  pid = spawn(spawn_env, *cmd,
553
555
  chdir: project["repo_path"],
554
- in: prompt_file,
556
+ **(prompt_mode == "stdin" ? { in: prompt_file } : {}),
555
557
  out: [log_file, "w"],
556
558
  err: %i[child out])
557
559
 
@@ -573,15 +575,16 @@ def write_cron_prompt_file(job, prompt_content, timestamp)
573
575
  end
574
576
 
575
577
  # Build the CLI command array for a cron agent invocation.
576
- def build_cron_agent_cmd(job, project)
578
+ def build_cron_agent_cmd(job, project, prompt_file: nil)
577
579
  agent_config_name = job[:agent].downcase.gsub(/[^a-z0-9-]/, "-")
578
- resolved = resolve_project_cli_config(project)
580
+ resolved = resolve_project_cli_config(project, agent_name: job[:agent])
581
+ agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
579
582
  cmd = [resolved["agent_cli"]]
580
- cmd.push("--agent", agent_config_name)
583
+ cmd.push(agent_flag, agent_config_name) if agent_flag
581
584
  cmd.concat(resolved["agent_cli_args"].split)
582
- add_trust_tools!(cmd, resolved["agent_cli_args"])
583
585
  cmd.push(resolved["agent_model_flag"], job[:model]) if resolved["agent_model_flag"]&.length&.positive? && job[:model]
584
586
  cmd.push(resolved["agent_effort_flag"], job[:effort]) if resolved["agent_effort_flag"]&.length&.positive? && job[:effort]
587
+ cmd.push(resolved["prompt_flag"], prompt_file) if prompt_file && resolved["prompt_mode"] == "flag" && resolved["prompt_flag"]
585
588
  cmd
586
589
  end
587
590
 
@@ -32,6 +32,24 @@ DISCORD_ALL_READY_LOGGED = { done: false }
32
32
  DISCORD_SHARED_THREADS = {}
33
33
  DISCORD_SHARED_THREADS_MUTEX = Mutex.new
34
34
 
35
+ # Discord thread worktree map: tracks worktrees created for Discord thread conversations.
36
+ # Keyed by "agent_key:channel_id" → { worktree, branch, project, created_at }
37
+ # Persisted to disk so sessions survive restarts.
38
+ DISCORD_THREAD_MAP_FILE = File.join(BRAINIAC_DIR, "discord_thread_map.json")
39
+ DISCORD_THREAD_MAP_MUTEX = Mutex.new
40
+
41
+ def load_discord_thread_map
42
+ return {} unless File.exist?(DISCORD_THREAD_MAP_FILE)
43
+
44
+ JSON.parse(File.read(DISCORD_THREAD_MAP_FILE))
45
+ rescue JSON::ParserError
46
+ {}
47
+ end
48
+
49
+ def save_discord_thread_map(map)
50
+ File.write(DISCORD_THREAD_MAP_FILE, JSON.pretty_generate(map))
51
+ end
52
+
35
53
  # Brainiac restart queue: when an agent works on brainiac itself, queue a restart
36
54
  # instead of doing it immediately. A background thread checks every 30s and only
37
55
  # restarts when no other agents are running, preventing mid-session kills.
@@ -721,6 +739,17 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
721
739
  # Strip effort tag (e.g. [effort:high]) from prompt content
722
740
  clean_content_for_prompt = clean_content_for_prompt.sub(/\[effort:\w+\]/i, "").strip
723
741
 
742
+ # Strip CLI provider tag (e.g. [cli:grok]) from prompt content
743
+ clean_content_for_prompt = clean_content_for_prompt.sub(/\[cli:\w+\]/i, "").strip
744
+
745
+ # Detect chat mode tag: [chat], [question], [?] — skips worktree, uses tmp dir instead
746
+ chat_mode = clean_content.match?(/\[(chat|question|\?)\]/i)
747
+ if chat_mode
748
+ clean_content = clean_content.sub(/\[(chat|question|\?)\]/i, "").strip
749
+ clean_content_for_prompt = clean_content_for_prompt.sub(/\[(chat|question|\?)\]/i, "").strip
750
+ LOG.info "[Discord:#{agent_name}] Chat mode detected — will skip worktree creation"
751
+ end
752
+
724
753
  # Find project: inline override > channel mapping > default_project
725
754
  if inline_project_key && PROJECTS.key?(inline_project_key)
726
755
  project_key = inline_project_key
@@ -816,14 +845,37 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
816
845
  # Also captures root message content so the agent always has the original context.
817
846
  root_message = find_root_message(message, channel_id, bot_token)
818
847
  root_message_id = root_message[:id]
819
- card_id = "discord-#{channel_id}-#{root_message_id}"
848
+
849
+ # Build a stable card_id for memory files. Inside a thread, use parent_channel + thread_id
850
+ # so all messages in the thread share one memory file. The thread's channel_id IS the thread
851
+ # starter message ID, which is stable across all messages within it.
852
+ card_id = if is_thread
853
+ "discord-#{parent_channel_id}-#{channel_id}"
854
+ else
855
+ "discord-#{channel_id}-#{root_message_id}"
856
+ end
820
857
 
821
858
  # Build thread root context — inject the original question/message that started
822
859
  # this thread so the agent never loses sight of it, even in long conversations.
823
860
  thread_root_context = ""
824
- if is_thread && root_message[:content] && !root_message[:content].empty?
825
- root_author = root_message[:author] || "unknown"
826
- thread_root_context = "### Original Message (thread starter)\n#{root_author}: #{root_message[:content]}\n\n"
861
+ if is_thread
862
+ root_content = root_message[:content]
863
+ root_author = root_message[:author]
864
+
865
+ # Fallback: if find_root_message didn't capture content (no message_reference chain),
866
+ # fetch the parent message directly. In Discord, a thread's channel_id equals the
867
+ # message_id that the thread was created on in the parent channel.
868
+ if root_content.nil? || root_content.empty?
869
+ parent_msg = fetch_discord_message(parent_channel_id, channel_id, token: bot_token, log_errors: false)
870
+ if parent_msg && parent_msg["content"] && !parent_msg["content"].strip.empty?
871
+ root_content = parent_msg["content"].strip
872
+ root_author = parent_msg.dig("author", "username") || "unknown"
873
+ end
874
+ end
875
+
876
+ if root_content && !root_content.empty?
877
+ thread_root_context = "### Original Message (thread starter)\n#{root_author || "unknown"}: #{root_content}\n\n"
878
+ end
827
879
  end
828
880
 
829
881
  # Detect planning mode
@@ -836,7 +888,165 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
836
888
 
837
889
  brain_context = build_brain_context(agent_name: agent_name, card_title: clean_content, comment_body: clean_content)
838
890
 
839
- if planning_info
891
+ # --- Worktree management & session resume ---
892
+ # Every dispatch with a project gets a dedicated worktree per agent+thread/channel.
893
+ # For channel messages, create a Discord thread upfront so we have a stable ID
894
+ # for the worktree, then dispatch in it. This ensures grok sessions persist
895
+ # across the thread conversation and can be resumed with -c.
896
+ should_resume = false
897
+ thread_worktree_path = nil
898
+ thread_cli_provider = nil
899
+ thread_model = nil
900
+ thread_effort = nil
901
+
902
+ # For channel messages (not DMs, not already in a thread), create a thread immediately
903
+ # so we can use its ID for the worktree. This also means the response delivery
904
+ # will find the pre-existing thread via the API lookup.
905
+ pre_created_thread_id = nil
906
+ if !is_thread && !is_dm && project_config
907
+ display_name = fizzy_display_name(agent_key)
908
+ thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
909
+ if thread && thread["id"]
910
+ pre_created_thread_id = thread["id"]
911
+ DISCORD_SHARED_THREADS_MUTEX.synchronize { DISCORD_SHARED_THREADS[message_id] = pre_created_thread_id }
912
+
913
+ # Propagate dispatch depth to the new thread
914
+ parent_depth_key = "discord-#{channel_id}"
915
+ thread_depth_key = "discord-#{pre_created_thread_id}"
916
+ parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
917
+ if parent_info
918
+ AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
919
+ else
920
+ record_human_comment(thread_depth_key)
921
+ end
922
+
923
+ LOG.info "[Discord:#{agent_name}] Pre-created thread #{pre_created_thread_id} for worktree isolation"
924
+ else
925
+ LOG.warn "[Discord:#{agent_name}] Failed to pre-create thread — will run without worktree isolation"
926
+ end
927
+ end
928
+
929
+ # Determine the thread map key: use the thread ID (existing thread or pre-created one)
930
+ effective_thread_id = is_thread ? channel_id : pre_created_thread_id
931
+ thread_map_key = "#{agent_key}:#{effective_thread_id}" if effective_thread_id
932
+
933
+ if project_config && thread_map_key
934
+ repo_path = project_config["repo_path"]
935
+ thread_map = DISCORD_THREAD_MAP_MUTEX.synchronize { load_discord_thread_map }
936
+ existing = thread_map[thread_map_key]
937
+
938
+ if existing && existing["chat_mode"]
939
+ # Thread was started in chat mode — reuse the tmp dir, don't create a worktree
940
+ thread_worktree_path = existing["worktree"]
941
+ thread_cli_provider = existing["cli_provider"]
942
+ thread_model = existing["model"]
943
+ thread_effort = existing["effort"]
944
+ chat_mode = true
945
+ effective_provider = detect_cli_provider(text: clean_content) || thread_cli_provider
946
+ resolved_for_resume = resolve_project_cli_config(project_config, cli_provider_override: effective_provider, agent_name: agent_name)
947
+ should_resume = resolved_for_resume["resume_flag"] ? true : false
948
+ LOG.info "[Discord:#{agent_name}] Reusing chat mode tmp dir at #{thread_worktree_path} (resume: #{should_resume})"
949
+ elsif chat_mode
950
+ # New chat mode session — skip worktree, tmp dir will be created below
951
+ LOG.info "[Discord:#{agent_name}] Chat mode — skipping worktree creation"
952
+ elsif existing && existing["worktree"] && File.directory?(existing["worktree"])
953
+ # Existing worktree — resume session. Inherit tags from the thread's first dispatch.
954
+ thread_worktree_path = existing["worktree"]
955
+ thread_cli_provider = existing["cli_provider"]
956
+ thread_model = existing["model"]
957
+ thread_effort = existing["effort"]
958
+ effective_provider = detect_cli_provider(text: clean_content) || thread_cli_provider
959
+ resolved_for_resume = resolve_project_cli_config(project_config, cli_provider_override: effective_provider, agent_name: agent_name)
960
+ should_resume = resolved_for_resume["resume_flag"] ? true : false
961
+ LOG.info "[Discord:#{agent_name}] Reusing thread worktree at #{thread_worktree_path} (resume: #{should_resume}, cli: #{effective_provider || "default"})"
962
+ else
963
+ # First worktree creation. Inherit tags from a seeded thread map entry if present.
964
+ seeded_cli_provider = existing&.dig("cli_provider")
965
+ seeded_model = existing&.dig("model")
966
+ seeded_effort = existing&.dig("effort")
967
+
968
+ thread_slug = effective_thread_id[-8..]
969
+ branch = "discord-#{agent_key}-#{thread_slug}"
970
+ thread_worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
971
+
972
+ debounced_repo_fetch(repo_path)
973
+ default_branch = get_default_branch(repo_path)
974
+
975
+ branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
976
+
977
+ if branch_exists
978
+ worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
979
+ has_worktree = worktree_list.lines.any? { |l| l.strip == "worktree #{thread_worktree_path}" }
980
+ if has_worktree && File.directory?(thread_worktree_path)
981
+ LOG.info "[Discord:#{agent_name}] Reusing existing worktree at #{thread_worktree_path}"
982
+ else
983
+ run_cmd("git", "worktree", "add", thread_worktree_path, branch, chdir: repo_path)
984
+ end
985
+ else
986
+ run_cmd("git", "worktree", "add", "-b", branch, thread_worktree_path, "origin/#{default_branch}", chdir: repo_path)
987
+ end
988
+
989
+ trust_version_manager(thread_worktree_path, chdir: thread_worktree_path)
990
+ apply_worktree_includes(repo_path, thread_worktree_path)
991
+ run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => thread_worktree_path })
992
+
993
+ # Store tags: prefer current message tag, then seeded value from initial dispatch
994
+ first_cli_provider = detect_cli_provider(text: clean_content) || seeded_cli_provider
995
+ first_model = (project_config ? detect_model(project_config, text: clean_content) : nil) || seeded_model
996
+ first_effort = (project_config ? detect_effort(project_config, text: clean_content) : nil) || seeded_effort
997
+ thread_cli_provider = first_cli_provider
998
+ thread_model = first_model
999
+ thread_effort = first_effort
1000
+
1001
+ DISCORD_THREAD_MAP_MUTEX.synchronize do
1002
+ map = load_discord_thread_map
1003
+ map[thread_map_key] = { "worktree" => thread_worktree_path, "branch" => branch,
1004
+ "project" => PROJECTS.find { |_k, v| v == project_config }&.first,
1005
+ "channel_id" => effective_thread_id, "cli_provider" => first_cli_provider,
1006
+ "model" => first_model, "effort" => first_effort,
1007
+ "created_at" => Time.now.iso8601 }
1008
+ save_discord_thread_map(map)
1009
+ end
1010
+ LOG.info "[Discord:#{agent_name}] Created thread worktree at #{thread_worktree_path}#{" (cli: #{first_cli_provider})" if first_cli_provider}#{" (model: #{first_model})" if first_model}#{" (effort: #{first_effort})" if first_effort}"
1011
+ end
1012
+ end
1013
+
1014
+ # Chat mode: create a tmp directory instead of a worktree for lightweight conversational sessions
1015
+ if chat_mode && !thread_worktree_path && thread_map_key
1016
+ chat_tmp_dir = File.join(BRAINIAC_DIR, "tmp", "chat", "#{agent_key}-#{effective_thread_id}")
1017
+ FileUtils.mkdir_p(chat_tmp_dir)
1018
+ thread_worktree_path = chat_tmp_dir
1019
+
1020
+ first_cli_provider = detect_cli_provider(text: clean_content)
1021
+ first_model = project_config ? detect_model(project_config, text: clean_content) : nil
1022
+ first_effort = project_config ? detect_effort(project_config, text: clean_content) : nil
1023
+ thread_cli_provider = first_cli_provider
1024
+ thread_model = first_model
1025
+ thread_effort = first_effort
1026
+
1027
+ DISCORD_THREAD_MAP_MUTEX.synchronize do
1028
+ map = load_discord_thread_map
1029
+ map[thread_map_key] = { "worktree" => chat_tmp_dir, "chat_mode" => true,
1030
+ "project" => PROJECTS.find { |_k, v| v == project_config }&.first,
1031
+ "channel_id" => effective_thread_id, "cli_provider" => first_cli_provider,
1032
+ "model" => first_model, "effort" => first_effort,
1033
+ "created_at" => Time.now.iso8601 }
1034
+ save_discord_thread_map(map)
1035
+ end
1036
+ LOG.info "[Discord:#{agent_name}] Created chat mode tmp dir at #{chat_tmp_dir}"
1037
+ end
1038
+
1039
+ if should_resume && thread_worktree_path
1040
+ # Lean resume prompt — prior session has full context
1041
+ prompt = render_discord_resume_prompt(
1042
+ message_body: clean_content_for_prompt,
1043
+ discord_user: discord_user,
1044
+ response_file: response_file,
1045
+ agent_name: agent_name,
1046
+ card_id: card_id
1047
+ )
1048
+ LOG.info "[Discord:#{agent_name}] Using resume prompt for thread #{channel_id}"
1049
+ elsif planning_info
840
1050
  # Planning mode — use planning prompt
841
1051
  planning_card_id = planning_info[:card_id]
842
1052
  LOG.info "[Discord:#{agent_name}] Planning mode detected for #{discord_user}"
@@ -875,11 +1085,55 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
875
1085
  channel: :discord)
876
1086
  end
877
1087
 
878
- work_dir = project_config ? project_config["repo_path"] : Dir.pwd
1088
+ # Chat mode fallback: if no thread_map_key was available (no thread created, e.g. DM or no project),
1089
+ # still use a tmp dir so we don't fall back to the project repo_path.
1090
+ if chat_mode && !thread_worktree_path
1091
+ chat_tmp_dir = File.join(BRAINIAC_DIR, "tmp", "chat", "#{agent_key}-#{message_id}")
1092
+ FileUtils.mkdir_p(chat_tmp_dir)
1093
+ thread_worktree_path = chat_tmp_dir
1094
+ LOG.info "[Discord:#{agent_name}] Chat mode fallback tmp dir at #{chat_tmp_dir}"
1095
+ end
1096
+
1097
+ work_dir = thread_worktree_path || (project_config ? project_config["repo_path"] : Dir.pwd)
879
1098
 
880
1099
  prompt_file = File.join(response_dir, "discord-prompt-#{timestamp}-#{agent_key}-#{message_id}.md")
881
1100
  File.write(prompt_file, prompt)
882
1101
 
1102
+ # Detect overrides from inline tags — [opus], [effort:high], [cli:grok]
1103
+ # Current message tags override thread defaults; thread defaults apply when no tag is present.
1104
+ cli_provider_override = detect_cli_provider(text: clean_content) || thread_cli_provider
1105
+
1106
+ # For model/effort: detect_model/detect_effort return the project default when no tag matches,
1107
+ # so we check for explicit tag presence to know whether the thread default should apply.
1108
+ has_explicit_model = false
1109
+ if project_config
1110
+ allowed_models = resolve_project_cli_config(project_config)["allowed_models"] || {}
1111
+ model_tag_match = clean_content.match(/\[(\w+)\]/i)
1112
+ has_explicit_model = model_tag_match && allowed_models.key?(model_tag_match[1].downcase)
1113
+ end
1114
+ has_explicit_effort = clean_content.match?(/\[effort:\w+\]/i)
1115
+
1116
+ model = if has_explicit_model
1117
+ detect_model(project_config, text: clean_content)
1118
+ elsif thread_model
1119
+ thread_model
1120
+ else
1121
+ project_config ? detect_model(project_config, text: clean_content) : nil
1122
+ end
1123
+
1124
+ effort = if has_explicit_effort
1125
+ detect_effort(project_config, text: clean_content)
1126
+ elsif thread_effort
1127
+ thread_effort
1128
+ else
1129
+ project_config ? detect_effort(project_config, text: clean_content) : nil
1130
+ end
1131
+
1132
+ # Determine the "explicit" values from this message only (not thread defaults)
1133
+ # for seeding into the thread map on initial dispatch.
1134
+ explicit_model = has_explicit_model ? model : nil
1135
+ explicit_effort = has_explicit_effort ? effort : nil
1136
+
883
1137
  # Write delivery metadata sidecar so the poller can post this response
884
1138
  # even if the monitoring thread dies (e.g. server restart).
885
1139
  meta_file = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.meta.json")
@@ -891,32 +1145,35 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
891
1145
  is_dm: is_dm,
892
1146
  is_thread: is_thread,
893
1147
  clean_content: clean_content[0..80],
1148
+ cli_provider: detect_cli_provider(text: clean_content),
1149
+ model: explicit_model,
1150
+ effort: explicit_effort,
894
1151
  created_at: Time.now.iso8601
895
1152
  }))
896
1153
 
897
- # Detect model override — same [opus]/[sonnet]/[haiku] syntax as Fizzy comments
898
- model = project_config ? detect_model(project_config, text: clean_content) : nil
899
-
900
- # Detect effort override — [effort:high] syntax
901
- effort = project_config ? detect_effort(project_config, text: clean_content) : nil
902
-
903
1154
  agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-")
904
1155
  log_file = File.join(response_dir, "discord-agent-#{timestamp}-#{agent_key}-#{message_id}.log")
905
1156
 
906
- resolved = project_config ? resolve_project_cli_config(project_config) : {}
1157
+ resolved = project_config ? resolve_project_cli_config(project_config, cli_provider_override: cli_provider_override, agent_name: agent_name) : {}
907
1158
  agent_cli = resolved["agent_cli"] || "kiro-cli"
908
1159
  agent_cli_args = resolved["agent_cli_args"] || "chat --trust-all-tools --no-interactive"
909
1160
  agent_model_flag = resolved["agent_model_flag"] || "--model"
910
1161
  agent_effort_flag = resolved["agent_effort_flag"] || "--effort"
1162
+ agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
1163
+ prompt_mode = resolved["prompt_mode"] || "stdin"
911
1164
 
912
1165
  cmd = [agent_cli]
913
- cmd.push("--agent", agent_config_name)
1166
+ cmd.push(agent_flag, agent_config_name) if agent_flag
914
1167
  cmd.concat(agent_cli_args.split)
915
- add_trust_tools!(cmd, agent_cli_args)
916
- cmd.push(agent_model_flag, model) if agent_model_flag && !agent_model_flag.empty? && model
1168
+ if model && agent_model_flag && !agent_model_flag.empty?
1169
+ allowed = resolved["allowed_models"] || {}
1170
+ cmd.push(agent_model_flag, model) if allowed.value?(model) || allowed.key?(model)
1171
+ end
917
1172
  cmd.push(agent_effort_flag, effort) if agent_effort_flag && !agent_effort_flag.empty? && effort
1173
+ cmd.push(resolved["resume_flag"]) if should_resume && resolved["resume_flag"]
1174
+ cmd.push(resolved["prompt_flag"], prompt_file) if prompt_mode == "flag" && resolved["prompt_flag"]
918
1175
 
919
- LOG.info "[Discord:#{agent_name}] Dispatching for #{discord_user} (model: #{model || "default"}, effort: #{effort || "default"}), tail -f #{log_file}"
1176
+ LOG.info "[Discord:#{agent_name}] Dispatching for #{discord_user} (model: #{model || "default"}, effort: #{effort || "default"}, cli: #{agent_cli}#{", resuming" if should_resume}), tail -f #{log_file}"
920
1177
  LOG.info "[Discord:#{agent_name}] Command: #{cmd.join(" ")}"
921
1178
 
922
1179
  spawn_env = {}
@@ -926,19 +1183,17 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
926
1183
  LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}"
927
1184
  end
928
1185
 
929
- # Capture HEAD before spawning so we can detect if THIS session made commits
1186
+ # Capture HEAD and dirty state before spawning so we can detect if THIS session made changes
930
1187
  head_before = nil
1188
+ status_before = nil
931
1189
  if project_config
932
1190
  pk = PROJECTS.find { |_k, v| v == project_config }&.first
933
- if pk == "brainiac"
934
- head_before, = Open3.capture2("git", "rev-parse", "HEAD", chdir: work_dir)
935
- head_before = head_before.strip
936
- end
1191
+ head_before, status_before = capture_git_state(work_dir) if pk == "brainiac"
937
1192
  end
938
1193
 
939
1194
  pid = spawn(spawn_env, *cmd,
940
1195
  chdir: work_dir,
941
- in: prompt_file,
1196
+ **(prompt_mode == "stdin" ? { in: prompt_file } : {}),
942
1197
  out: [log_file, "w"],
943
1198
  err: %i[child out])
944
1199
 
@@ -1043,14 +1298,12 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
1043
1298
  brain_push(message: "#{agent_name}: discord-#{message_id}")
1044
1299
 
1045
1300
  # Restart brainiac if THIS session actually changed code
1046
- # Compare HEAD now vs before the agent ran — only restart if commits were made or files are dirty
1301
+ # Compare HEAD and dirty state now vs before — only restart if the agent made commits or new dirty files
1047
1302
  if project_config && head_before
1048
1303
  project_key = PROJECTS.find { |_k, v| v == project_config }&.first
1049
1304
  if project_key == "brainiac"
1050
- chdir = project_config["repo_path"]
1051
- head_after, = Open3.capture2("git", "rev-parse", "HEAD", chdir: chdir)
1052
- git_status, = Open3.capture2("git", "status", "--porcelain", chdir: chdir)
1053
- if head_after.strip != head_before || !git_status.strip.empty?
1305
+ head_after, status_after = capture_git_state(project_config["repo_path"])
1306
+ if head_after != head_before || status_after != status_before
1054
1307
  queue_brainiac_restart(agent_name)
1055
1308
  else
1056
1309
  LOG.info "[Brainiac] #{agent_name} Discord session on brainiac had no changes — skipping restart"
@@ -1624,10 +1877,16 @@ def start_all_discord_gateways
1624
1877
  end
1625
1878
 
1626
1879
  LOG.info "[Discord] Starting #{tokens.size} bot(s): #{tokens.keys.join(", ")}"
1627
- tokens.each do |agent_key, token|
1628
- DISCORD_BOTS_MUTEX.synchronize do
1880
+
1881
+ # Register all bots upfront so the "all ready" check sees the full set
1882
+ # before any individual READY event fires.
1883
+ DISCORD_BOTS_MUTEX.synchronize do
1884
+ tokens.each do |agent_key, token|
1629
1885
  DISCORD_BOTS[agent_key] = { token: token, status: "starting", user_id: nil }
1630
1886
  end
1887
+ end
1888
+
1889
+ tokens.each do |agent_key, token|
1631
1890
  start_discord_gateway_for(agent_key, token)
1632
1891
  sleep 1 # Stagger connections to avoid rate limits
1633
1892
  end