brainiac 0.0.1 → 0.0.3

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.
@@ -32,7 +32,25 @@ DISCORD_ALL_READY_LOGGED = { done: false }
32
32
  DISCORD_SHARED_THREADS = {}
33
33
  DISCORD_SHARED_THREADS_MUTEX = Mutex.new
34
34
 
35
- # Zillacore restart queue: when an agent works on brainiac itself, queue a restart
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
+
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.
38
56
  # Using a hash instead of a constant to allow mutation inside synchronize blocks
@@ -721,6 +739,9 @@ 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
+
724
745
  # Find project: inline override > channel mapping > default_project
725
746
  if inline_project_key && PROJECTS.key?(inline_project_key)
726
747
  project_key = inline_project_key
@@ -816,14 +837,37 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
816
837
  # Also captures root message content so the agent always has the original context.
817
838
  root_message = find_root_message(message, channel_id, bot_token)
818
839
  root_message_id = root_message[:id]
819
- card_id = "discord-#{channel_id}-#{root_message_id}"
840
+
841
+ # Build a stable card_id for memory files. Inside a thread, use parent_channel + thread_id
842
+ # so all messages in the thread share one memory file. The thread's channel_id IS the thread
843
+ # starter message ID, which is stable across all messages within it.
844
+ card_id = if is_thread
845
+ "discord-#{parent_channel_id}-#{channel_id}"
846
+ else
847
+ "discord-#{channel_id}-#{root_message_id}"
848
+ end
820
849
 
821
850
  # Build thread root context — inject the original question/message that started
822
851
  # this thread so the agent never loses sight of it, even in long conversations.
823
852
  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"
853
+ if is_thread
854
+ root_content = root_message[:content]
855
+ root_author = root_message[:author]
856
+
857
+ # Fallback: if find_root_message didn't capture content (no message_reference chain),
858
+ # fetch the parent message directly. In Discord, a thread's channel_id equals the
859
+ # message_id that the thread was created on in the parent channel.
860
+ if root_content.nil? || root_content.empty?
861
+ parent_msg = fetch_discord_message(parent_channel_id, channel_id, token: bot_token, log_errors: false)
862
+ if parent_msg && parent_msg["content"] && !parent_msg["content"].strip.empty?
863
+ root_content = parent_msg["content"].strip
864
+ root_author = parent_msg.dig("author", "username") || "unknown"
865
+ end
866
+ end
867
+
868
+ if root_content && !root_content.empty?
869
+ thread_root_context = "### Original Message (thread starter)\n#{root_author || "unknown"}: #{root_content}\n\n"
870
+ end
827
871
  end
828
872
 
829
873
  # Detect planning mode
@@ -836,7 +880,126 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
836
880
 
837
881
  brain_context = build_brain_context(agent_name: agent_name, card_title: clean_content, comment_body: clean_content)
838
882
 
839
- if planning_info
883
+ # --- Worktree management & session resume ---
884
+ # Every dispatch with a project gets a dedicated worktree per agent+thread/channel.
885
+ # For channel messages, create a Discord thread upfront so we have a stable ID
886
+ # for the worktree, then dispatch in it. This ensures grok sessions persist
887
+ # across the thread conversation and can be resumed with -c.
888
+ should_resume = false
889
+ thread_worktree_path = nil
890
+ thread_cli_provider = nil
891
+ thread_model = nil
892
+ thread_effort = nil
893
+
894
+ # For channel messages (not DMs, not already in a thread), create a thread immediately
895
+ # so we can use its ID for the worktree. This also means the response delivery
896
+ # will find the pre-existing thread via the API lookup.
897
+ pre_created_thread_id = nil
898
+ if !is_thread && !is_dm && project_config
899
+ display_name = fizzy_display_name(agent_key)
900
+ thread = create_discord_thread(channel_id, message_id, name: "#{display_name}: #{clean_content[0..80]}", token: bot_token)
901
+ if thread && thread["id"]
902
+ pre_created_thread_id = thread["id"]
903
+ DISCORD_SHARED_THREADS_MUTEX.synchronize { DISCORD_SHARED_THREADS[message_id] = pre_created_thread_id }
904
+
905
+ # Propagate dispatch depth to the new thread
906
+ parent_depth_key = "discord-#{channel_id}"
907
+ thread_depth_key = "discord-#{pre_created_thread_id}"
908
+ parent_info = AGENT_DISPATCH_DEPTH[parent_depth_key]
909
+ if parent_info
910
+ AGENT_DISPATCH_DEPTH[thread_depth_key] = { count: 0, last_human_at: parent_info[:last_human_at] }
911
+ else
912
+ record_human_comment(thread_depth_key)
913
+ end
914
+
915
+ LOG.info "[Discord:#{agent_name}] Pre-created thread #{pre_created_thread_id} for worktree isolation"
916
+ else
917
+ LOG.warn "[Discord:#{agent_name}] Failed to pre-create thread — will run without worktree isolation"
918
+ end
919
+ end
920
+
921
+ # Determine the thread map key: use the thread ID (existing thread or pre-created one)
922
+ effective_thread_id = is_thread ? channel_id : pre_created_thread_id
923
+ thread_map_key = "#{agent_key}:#{effective_thread_id}" if effective_thread_id
924
+
925
+ if project_config && thread_map_key
926
+ repo_path = project_config["repo_path"]
927
+ thread_map = DISCORD_THREAD_MAP_MUTEX.synchronize { load_discord_thread_map }
928
+ existing = thread_map[thread_map_key]
929
+
930
+ if existing && existing["worktree"] && File.directory?(existing["worktree"])
931
+ # Existing worktree — resume session. Inherit tags from the thread's first dispatch.
932
+ thread_worktree_path = existing["worktree"]
933
+ thread_cli_provider = existing["cli_provider"]
934
+ thread_model = existing["model"]
935
+ thread_effort = existing["effort"]
936
+ effective_provider = detect_cli_provider(text: clean_content) || thread_cli_provider
937
+ resolved_for_resume = resolve_project_cli_config(project_config, cli_provider_override: effective_provider, agent_name: agent_name)
938
+ should_resume = resolved_for_resume["resume_flag"] ? true : false
939
+ LOG.info "[Discord:#{agent_name}] Reusing thread worktree at #{thread_worktree_path} (resume: #{should_resume}, cli: #{effective_provider || "default"})"
940
+ else
941
+ # First worktree creation. Inherit tags from a seeded thread map entry if present.
942
+ seeded_cli_provider = existing&.dig("cli_provider")
943
+ seeded_model = existing&.dig("model")
944
+ seeded_effort = existing&.dig("effort")
945
+
946
+ thread_slug = effective_thread_id[-8..]
947
+ branch = "discord-#{agent_key}-#{thread_slug}"
948
+ thread_worktree_path = File.join(File.dirname(repo_path), "#{File.basename(repo_path)}--#{branch}")
949
+
950
+ debounced_repo_fetch(repo_path)
951
+ default_branch = get_default_branch(repo_path)
952
+
953
+ branch_exists = system("git", "rev-parse", "--verify", branch, chdir: repo_path, out: File::NULL, err: File::NULL)
954
+
955
+ if branch_exists
956
+ worktree_list = run_cmd("git", "worktree", "list", "--porcelain", chdir: repo_path)
957
+ has_worktree = worktree_list.lines.any? { |l| l.strip == "worktree #{thread_worktree_path}" }
958
+ if has_worktree && File.directory?(thread_worktree_path)
959
+ LOG.info "[Discord:#{agent_name}] Reusing existing worktree at #{thread_worktree_path}"
960
+ else
961
+ run_cmd("git", "worktree", "add", thread_worktree_path, branch, chdir: repo_path)
962
+ end
963
+ else
964
+ run_cmd("git", "worktree", "add", "-b", branch, thread_worktree_path, "origin/#{default_branch}", chdir: repo_path)
965
+ end
966
+
967
+ trust_version_manager(thread_worktree_path, chdir: thread_worktree_path)
968
+ apply_worktree_includes(repo_path, thread_worktree_path)
969
+ run_project_hook(repo_path, "worktree-setup", extra_env: { "WORKTREE_PATH" => thread_worktree_path })
970
+
971
+ # Store tags: prefer current message tag, then seeded value from initial dispatch
972
+ first_cli_provider = detect_cli_provider(text: clean_content) || seeded_cli_provider
973
+ first_model = (project_config ? detect_model(project_config, text: clean_content) : nil) || seeded_model
974
+ first_effort = (project_config ? detect_effort(project_config, text: clean_content) : nil) || seeded_effort
975
+ thread_cli_provider = first_cli_provider
976
+ thread_model = first_model
977
+ thread_effort = first_effort
978
+
979
+ DISCORD_THREAD_MAP_MUTEX.synchronize do
980
+ map = load_discord_thread_map
981
+ map[thread_map_key] = { "worktree" => thread_worktree_path, "branch" => branch,
982
+ "project" => PROJECTS.find { |_k, v| v == project_config }&.first,
983
+ "channel_id" => effective_thread_id, "cli_provider" => first_cli_provider,
984
+ "model" => first_model, "effort" => first_effort,
985
+ "created_at" => Time.now.iso8601 }
986
+ save_discord_thread_map(map)
987
+ end
988
+ 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}"
989
+ end
990
+ end
991
+
992
+ if should_resume && thread_worktree_path
993
+ # Lean resume prompt — prior session has full context
994
+ prompt = render_discord_resume_prompt(
995
+ message_body: clean_content_for_prompt,
996
+ discord_user: discord_user,
997
+ response_file: response_file,
998
+ agent_name: agent_name,
999
+ card_id: card_id
1000
+ )
1001
+ LOG.info "[Discord:#{agent_name}] Using resume prompt for thread #{channel_id}"
1002
+ elsif planning_info
840
1003
  # Planning mode — use planning prompt
841
1004
  planning_card_id = planning_info[:card_id]
842
1005
  LOG.info "[Discord:#{agent_name}] Planning mode detected for #{discord_user}"
@@ -875,11 +1038,46 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
875
1038
  channel: :discord)
876
1039
  end
877
1040
 
878
- work_dir = project_config ? project_config["repo_path"] : Dir.pwd
1041
+ work_dir = thread_worktree_path || (project_config ? project_config["repo_path"] : Dir.pwd)
879
1042
 
880
1043
  prompt_file = File.join(response_dir, "discord-prompt-#{timestamp}-#{agent_key}-#{message_id}.md")
881
1044
  File.write(prompt_file, prompt)
882
1045
 
1046
+ # Detect overrides from inline tags — [opus], [effort:high], [cli:grok]
1047
+ # Current message tags override thread defaults; thread defaults apply when no tag is present.
1048
+ cli_provider_override = detect_cli_provider(text: clean_content) || thread_cli_provider
1049
+
1050
+ # For model/effort: detect_model/detect_effort return the project default when no tag matches,
1051
+ # so we check for explicit tag presence to know whether the thread default should apply.
1052
+ has_explicit_model = false
1053
+ if project_config
1054
+ allowed_models = resolve_project_cli_config(project_config)["allowed_models"] || {}
1055
+ model_tag_match = clean_content.match(/\[(\w+)\]/i)
1056
+ has_explicit_model = model_tag_match && allowed_models.key?(model_tag_match[1].downcase)
1057
+ end
1058
+ has_explicit_effort = clean_content.match?(/\[effort:\w+\]/i)
1059
+
1060
+ model = if has_explicit_model
1061
+ detect_model(project_config, text: clean_content)
1062
+ elsif thread_model
1063
+ thread_model
1064
+ else
1065
+ project_config ? detect_model(project_config, text: clean_content) : nil
1066
+ end
1067
+
1068
+ effort = if has_explicit_effort
1069
+ detect_effort(project_config, text: clean_content)
1070
+ elsif thread_effort
1071
+ thread_effort
1072
+ else
1073
+ project_config ? detect_effort(project_config, text: clean_content) : nil
1074
+ end
1075
+
1076
+ # Determine the "explicit" values from this message only (not thread defaults)
1077
+ # for seeding into the thread map on initial dispatch.
1078
+ explicit_model = has_explicit_model ? model : nil
1079
+ explicit_effort = has_explicit_effort ? effort : nil
1080
+
883
1081
  # Write delivery metadata sidecar so the poller can post this response
884
1082
  # even if the monitoring thread dies (e.g. server restart).
885
1083
  meta_file = File.join(DISCORD_DRAFT_DIR, "#{response_basename}.meta.json")
@@ -891,32 +1089,35 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
891
1089
  is_dm: is_dm,
892
1090
  is_thread: is_thread,
893
1091
  clean_content: clean_content[0..80],
1092
+ cli_provider: detect_cli_provider(text: clean_content),
1093
+ model: explicit_model,
1094
+ effort: explicit_effort,
894
1095
  created_at: Time.now.iso8601
895
1096
  }))
896
1097
 
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
1098
  agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-")
904
1099
  log_file = File.join(response_dir, "discord-agent-#{timestamp}-#{agent_key}-#{message_id}.log")
905
1100
 
906
- resolved = project_config ? resolve_project_cli_config(project_config) : {}
1101
+ resolved = project_config ? resolve_project_cli_config(project_config, cli_provider_override: cli_provider_override, agent_name: agent_name) : {}
907
1102
  agent_cli = resolved["agent_cli"] || "kiro-cli"
908
1103
  agent_cli_args = resolved["agent_cli_args"] || "chat --trust-all-tools --no-interactive"
909
1104
  agent_model_flag = resolved["agent_model_flag"] || "--model"
910
1105
  agent_effort_flag = resolved["agent_effort_flag"] || "--effort"
1106
+ agent_flag = resolved.key?("agent_flag") ? resolved["agent_flag"] : "--agent"
1107
+ prompt_mode = resolved["prompt_mode"] || "stdin"
911
1108
 
912
1109
  cmd = [agent_cli]
913
- cmd.push("--agent", agent_config_name)
1110
+ cmd.push(agent_flag, agent_config_name) if agent_flag
914
1111
  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
1112
+ if model && agent_model_flag && !agent_model_flag.empty?
1113
+ allowed = resolved["allowed_models"] || {}
1114
+ cmd.push(agent_model_flag, model) if allowed.value?(model) || allowed.key?(model)
1115
+ end
917
1116
  cmd.push(agent_effort_flag, effort) if agent_effort_flag && !agent_effort_flag.empty? && effort
1117
+ cmd.push(resolved["resume_flag"]) if should_resume && resolved["resume_flag"]
1118
+ cmd.push(resolved["prompt_flag"], prompt_file) if prompt_mode == "flag" && resolved["prompt_flag"]
918
1119
 
919
- LOG.info "[Discord:#{agent_name}] Dispatching for #{discord_user} (model: #{model || "default"}, effort: #{effort || "default"}), tail -f #{log_file}"
1120
+ 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
1121
  LOG.info "[Discord:#{agent_name}] Command: #{cmd.join(" ")}"
921
1122
 
922
1123
  spawn_env = {}
@@ -926,19 +1127,17 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
926
1127
  LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}"
927
1128
  end
928
1129
 
929
- # Capture HEAD before spawning so we can detect if THIS session made commits
1130
+ # Capture HEAD and dirty state before spawning so we can detect if THIS session made changes
930
1131
  head_before = nil
1132
+ status_before = nil
931
1133
  if project_config
932
1134
  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
1135
+ head_before, status_before = capture_git_state(work_dir) if pk == "brainiac"
937
1136
  end
938
1137
 
939
1138
  pid = spawn(spawn_env, *cmd,
940
1139
  chdir: work_dir,
941
- in: prompt_file,
1140
+ **(prompt_mode == "stdin" ? { in: prompt_file } : {}),
942
1141
  out: [log_file, "w"],
943
1142
  err: %i[child out])
944
1143
 
@@ -1043,14 +1242,12 @@ def handle_discord_message(message, agent_key, bot_token, bot_user_id)
1043
1242
  brain_push(message: "#{agent_name}: discord-#{message_id}")
1044
1243
 
1045
1244
  # 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
1245
+ # Compare HEAD and dirty state now vs before — only restart if the agent made commits or new dirty files
1047
1246
  if project_config && head_before
1048
1247
  project_key = PROJECTS.find { |_k, v| v == project_config }&.first
1049
1248
  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?
1249
+ head_after, status_after = capture_git_state(project_config["repo_path"])
1250
+ if head_after != head_before || status_after != status_before
1054
1251
  queue_brainiac_restart(agent_name)
1055
1252
  else
1056
1253
  LOG.info "[Brainiac] #{agent_name} Discord session on brainiac had no changes — skipping restart"
@@ -1624,10 +1821,16 @@ def start_all_discord_gateways
1624
1821
  end
1625
1822
 
1626
1823
  LOG.info "[Discord] Starting #{tokens.size} bot(s): #{tokens.keys.join(", ")}"
1627
- tokens.each do |agent_key, token|
1628
- DISCORD_BOTS_MUTEX.synchronize do
1824
+
1825
+ # Register all bots upfront so the "all ready" check sees the full set
1826
+ # before any individual READY event fires.
1827
+ DISCORD_BOTS_MUTEX.synchronize do
1828
+ tokens.each do |agent_key, token|
1629
1829
  DISCORD_BOTS[agent_key] = { token: token, status: "starting", user_id: nil }
1630
1830
  end
1831
+ end
1832
+
1833
+ tokens.each do |agent_key, token|
1631
1834
  start_discord_gateway_for(agent_key, token)
1632
1835
  sleep 1 # Stagger connections to avoid rate limits
1633
1836
  end
@@ -143,6 +143,7 @@ def handle_card_assigned(payload)
143
143
  branch = "fizzy-#{card_number}-#{slugify(title)}"
144
144
  model = detect_model(project_config, tags: tags)
145
145
  effort = detect_effort(project_config, tags: tags)
146
+ cli_provider_override = detect_cli_provider(tags: tags)
146
147
 
147
148
  card_key = "card-#{card_number}"
148
149
  if session_active?(card_key)
@@ -274,7 +275,7 @@ def handle_card_assigned(payload)
274
275
  end
275
276
 
276
277
  pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree_path, log_name: "assigned-#{card_number}", model: model, effort: effort, agent_name: agent_name,
277
- card_number: card_number, source: :fizzy, source_context: { card_number: card_number })
278
+ card_number: card_number, source: :fizzy, source_context: { card_number: card_number }, cli_provider: cli_provider_override)
278
279
  register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: assigned_agent)
279
280
 
280
281
  # Move card to Right Now — agent is starting work
@@ -635,8 +636,13 @@ def handle_comment(payload)
635
636
  end
636
637
  end
637
638
 
639
+ card_tags = eventable.dig("card", "tags") || []
638
640
  model = detect_model(project_config, text: plain_text)
639
- effort = detect_effort(project_config, tags: tags, text: effort_text_for_detection)
641
+ effort = detect_effort(project_config, tags: card_tags, text: effort_text_for_detection)
642
+ cli_provider_override = detect_cli_provider(text: plain_text, tags: card_tags)
643
+
644
+ # Strip [cli:X] tag from prompt content
645
+ plain_text = plain_text.sub(/\[cli:\w+\]/i, "").strip
640
646
 
641
647
  # Determine which agent should handle this comment.
642
648
  #
@@ -826,7 +832,7 @@ def handle_comment(payload)
826
832
  pid, log_file = run_agent(prompt, project_config: project_config, chdir: review_worktree_path,
827
833
  log_name: "review-#{agent_name.downcase}-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name,
828
834
  card_number: card_number, comment_id: comment_id,
829
- source: :fizzy, source_context: { card_number: card_number })
835
+ source: :fizzy, source_context: { card_number: card_number }, cli_provider: cli_provider_override)
830
836
  register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)
831
837
 
832
838
  return [200, { status: "cross_agent_review", agent: agent_name, card_agent: card_assigned_agent,
@@ -912,7 +918,7 @@ def handle_comment(payload)
912
918
  work_dir: work_dir, project_config: project_config, project_key: project_key,
913
919
  comment_vars: comment_vars, plain_text: plain_text, model: model,
914
920
  agent_name: agent_name, comment_id: comment_id, eventable: eventable,
915
- deploy_intent: deploy_intent
921
+ deploy_intent: deploy_intent, cli_provider: cli_provider_override
916
922
  )
917
923
  end
918
924
 
@@ -936,7 +942,7 @@ def handle_comment(payload)
936
942
  work_dir: work_dir, project_config: project_config, project_key: project_key,
937
943
  comment_vars: comment_vars, plain_text: plain_text, model: model,
938
944
  agent_name: agent_name, comment_id: comment_id, eventable: eventable,
939
- deploy_intent: deploy_intent
945
+ deploy_intent: deploy_intent, cli_provider: cli_provider_override
940
946
  )
941
947
  [200, result.to_json]
942
948
  else
@@ -1019,47 +1025,63 @@ def handle_comment(payload)
1019
1025
 
1020
1026
  # Detect planning mode
1021
1027
  card_tags = eventable.dig("card", "tags") || []
1022
- planning_info = detect_planning_mode(
1023
- text: plain_text,
1024
- tags: card_tags,
1025
- card_internal_id: card_internal_id,
1026
- card_number: card_number
1027
- )
1028
-
1029
- prompt = if planning_info
1030
- # Planning mode
1031
- card_id = planning_info[:card_id]
1032
- LOG.info "[Planning] Planning mode active for mention on card #{card_number || card_internal_id}"
1033
-
1034
- render_planning_prompt(PROMPT_MENTION,
1035
- comment_vars.merge(
1036
- "CARD_INTERNAL_ID" => card_internal_id,
1037
- "CARD_ID" => card_id,
1038
- "CARD_NUMBER" => card_number || "N/A",
1039
- "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "",
1040
- "BRANCH" => branch
1041
- ),
1042
- brain_context: build_brain_context(agent_name: agent_name, card_title: card_title, card_number: card_number, project_key: project_key,
1043
- comment_body: plain_text, source: :fizzy),
1044
- card_context: prefetch_card_context(card_number, repo_path: worktree_path, agent_name: agent_name),
1045
- agent_name: agent_name)
1046
- else
1047
- render_prompt(PROMPT_MENTION,
1048
- comment_vars.merge(
1049
- "CARD_INTERNAL_ID" => card_internal_id,
1050
- "CARD_ID" => card_number || card_internal_id,
1051
- "CARD_NUMBER" => card_number || "N/A",
1052
- "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "",
1053
- "BRANCH" => branch
1054
- ),
1055
- brain_context: build_brain_context(agent_name: agent_name, card_title: card_title, card_number: card_number, project_key: project_key,
1056
- comment_body: plain_text, source: :fizzy),
1057
- card_context: prefetch_card_context(card_number, repo_path: worktree_path, agent_name: agent_name),
1058
- agent_name: agent_name)
1059
- end
1028
+
1029
+ # Check if we can resume the existing session (lean prompt) vs full prompt
1030
+ resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider_override, agent_name: agent_name)
1031
+ should_resume = resolved["resume_flag"]
1032
+
1033
+ if should_resume
1034
+ prompt = render_resume_prompt(
1035
+ comment_body: plain_text,
1036
+ comment_creator: comment_vars["COMMENT_CREATOR"],
1037
+ comment_id: comment_id,
1038
+ card_number: card_number,
1039
+ agent_name: agent_name
1040
+ )
1041
+ LOG.info "[Resume] Using lean prompt for mention on card #{card_number || card_internal_id} (#{resolved["agent_cli"]})"
1042
+ else
1043
+ planning_info = detect_planning_mode(
1044
+ text: plain_text,
1045
+ tags: card_tags,
1046
+ card_internal_id: card_internal_id,
1047
+ card_number: card_number
1048
+ )
1049
+
1050
+ prompt = if planning_info
1051
+ # Planning mode
1052
+ card_id = planning_info[:card_id]
1053
+ LOG.info "[Planning] Planning mode active for mention on card #{card_number || card_internal_id}"
1054
+
1055
+ render_planning_prompt(PROMPT_MENTION,
1056
+ comment_vars.merge(
1057
+ "CARD_INTERNAL_ID" => card_internal_id,
1058
+ "CARD_ID" => card_id,
1059
+ "CARD_NUMBER" => card_number || "N/A",
1060
+ "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "",
1061
+ "BRANCH" => branch
1062
+ ),
1063
+ brain_context: build_brain_context(agent_name: agent_name, card_title: card_title, card_number: card_number, project_key: project_key,
1064
+ comment_body: plain_text, source: :fizzy),
1065
+ card_context: prefetch_card_context(card_number, repo_path: worktree_path, agent_name: agent_name),
1066
+ agent_name: agent_name)
1067
+ else
1068
+ render_prompt(PROMPT_MENTION,
1069
+ comment_vars.merge(
1070
+ "CARD_INTERNAL_ID" => card_internal_id,
1071
+ "CARD_ID" => card_number || card_internal_id,
1072
+ "CARD_NUMBER" => card_number || "N/A",
1073
+ "CARD_NUMBER_TEXT" => card_number ? " (##{card_number})" : "",
1074
+ "BRANCH" => branch
1075
+ ),
1076
+ brain_context: build_brain_context(agent_name: agent_name, card_title: card_title, card_number: card_number, project_key: project_key,
1077
+ comment_body: plain_text, source: :fizzy),
1078
+ card_context: prefetch_card_context(card_number, repo_path: worktree_path, agent_name: agent_name),
1079
+ agent_name: agent_name)
1080
+ end
1081
+ end
1060
1082
 
1061
1083
  pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree_path, log_name: "mention-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name, card_number: card_number, comment_id: comment_id,
1062
- source: :fizzy, source_context: { card_number: card_number })
1084
+ source: :fizzy, source_context: { card_number: card_number }, cli_provider: cli_provider_override, resume: true)
1063
1085
  register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)
1064
1086
  return [200,
1065
1087
  { status: "responded", card_internal_id: card_internal_id, card_number: card_number, branch: branch, worktree: worktree_path,
@@ -1182,7 +1204,7 @@ def handle_comment(payload)
1182
1204
  end
1183
1205
 
1184
1206
  pid, log_file = run_agent(prompt, project_config: project_config, chdir: worktree_path, log_name: "mention-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name, card_number: card_number, comment_id: comment_id,
1185
- source: :fizzy, source_context: { card_number: card_number })
1207
+ source: :fizzy, source_context: { card_number: card_number }, cli_provider: cli_provider_override)
1186
1208
  register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)
1187
1209
  [200,
1188
1210
  { status: "responded", card_internal_id: card_internal_id, card_number: card_number, branch: branch, worktree: worktree_path,
@@ -1193,53 +1215,74 @@ end
1193
1215
  # Dispatch a follow-up comment to the agent. Extracted so it can be called
1194
1216
  # both inline (no active session) and from a queued background thread.
1195
1217
  def dispatch_followup_comment(card_key:, card_number:, card_internal_id:, work_dir:, project_config:, project_key:, comment_vars:, plain_text:,
1196
- model:, agent_name:, comment_id:, eventable:, deploy_intent: nil)
1218
+ model:, agent_name:, comment_id:, eventable:, deploy_intent: nil, cli_provider: nil)
1197
1219
  card_tags = eventable.dig("card", "tags") || []
1198
- planning_info = detect_planning_mode(
1199
- text: plain_text,
1200
- tags: card_tags,
1201
- card_internal_id: card_internal_id,
1202
- card_number: card_number
1203
- )
1220
+ effort = detect_effort(project_config, tags: card_tags, text: plain_text)
1221
+
1222
+ # Determine if we should resume (worktree + provider supports it)
1223
+ is_worktree = work_dir != project_config["repo_path"]
1224
+ resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
1225
+ should_resume = is_worktree && resolved["resume_flag"]
1226
+
1227
+ if should_resume
1228
+ # Lean prompt: only the new comment. The previous session
1229
+ # already has role, persona, knowledge, core instructions, and card history.
1230
+ prompt = render_resume_prompt(
1231
+ comment_body: plain_text,
1232
+ comment_creator: comment_vars["COMMENT_CREATOR"],
1233
+ comment_id: comment_id,
1234
+ card_number: card_number,
1235
+ agent_name: agent_name
1236
+ )
1237
+ LOG.info "[Resume] Using lean prompt for follow-up on card #{card_number || card_internal_id} (#{resolved["agent_cli"]})"
1238
+ else
1239
+ # Full prompt: no session to resume, build everything from scratch
1240
+ planning_info = detect_planning_mode(
1241
+ text: plain_text,
1242
+ tags: card_tags,
1243
+ card_internal_id: card_internal_id,
1244
+ card_number: card_number
1245
+ )
1204
1246
 
1205
- prompt = if planning_info
1206
- card_id = planning_info[:card_id]
1207
- LOG.info "[Planning] Planning mode active for card #{card_number || card_internal_id}"
1208
-
1209
- if work_dir == project_config["repo_path"]
1210
- render_planning_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
1211
- comment_vars.merge("CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_id),
1212
- brain_context: build_brain_context(agent_name: agent_name, project_key: project_key, comment_body: plain_text,
1213
- source: :fizzy),
1214
- card_context: prefetch_card_context(card_number, repo_path: project_config["repo_path"],
1215
- agent_name: agent_name),
1216
- agent_name: agent_name)
1247
+ prompt = if planning_info
1248
+ card_id = planning_info[:card_id]
1249
+ LOG.info "[Planning] Planning mode active for card #{card_number || card_internal_id}"
1250
+
1251
+ if work_dir == project_config["repo_path"]
1252
+ render_planning_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
1253
+ comment_vars.merge("CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_id),
1254
+ brain_context: build_brain_context(agent_name: agent_name, project_key: project_key, comment_body: plain_text,
1255
+ source: :fizzy),
1256
+ card_context: prefetch_card_context(card_number, repo_path: project_config["repo_path"],
1257
+ agent_name: agent_name),
1258
+ agent_name: agent_name)
1259
+ else
1260
+ render_planning_prompt(PROMPT_FOLLOWUP_WORKTREE,
1261
+ comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_id),
1262
+ brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key, comment_body: plain_text,
1263
+ source: :fizzy),
1264
+ card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: agent_name),
1265
+ agent_name: agent_name)
1266
+ end
1267
+ elsif work_dir != project_config["repo_path"]
1268
+ render_prompt(PROMPT_FOLLOWUP_WORKTREE,
1269
+ comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_number),
1270
+ brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key, comment_body: plain_text,
1271
+ source: :fizzy),
1272
+ card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: agent_name),
1273
+ agent_name: agent_name)
1217
1274
  else
1218
- render_planning_prompt(PROMPT_FOLLOWUP_WORKTREE,
1219
- comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_id),
1220
- brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key, comment_body: plain_text,
1221
- source: :fizzy),
1222
- card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: agent_name),
1223
- agent_name: agent_name)
1275
+ render_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
1276
+ comment_vars.merge("CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_internal_id),
1277
+ brain_context: build_brain_context(agent_name: agent_name, project_key: project_key, comment_body: plain_text,
1278
+ source: :fizzy),
1279
+ card_context: prefetch_card_context(card_number, repo_path: project_config["repo_path"], agent_name: agent_name),
1280
+ agent_name: agent_name)
1224
1281
  end
1225
- elsif work_dir != project_config["repo_path"]
1226
- render_prompt(PROMPT_FOLLOWUP_WORKTREE,
1227
- comment_vars.merge("CARD_NUMBER" => card_number, "CARD_ID" => card_number),
1228
- brain_context: build_brain_context(agent_name: agent_name, card_number: card_number, project_key: project_key, comment_body: plain_text,
1229
- source: :fizzy),
1230
- card_context: prefetch_card_context(card_number, repo_path: work_dir, agent_name: agent_name),
1231
- agent_name: agent_name)
1232
- else
1233
- render_prompt(PROMPT_FOLLOWUP_NO_WORKTREE,
1234
- comment_vars.merge("CARD_INTERNAL_ID" => card_internal_id, "CARD_ID" => card_internal_id),
1235
- brain_context: build_brain_context(agent_name: agent_name, project_key: project_key, comment_body: plain_text,
1236
- source: :fizzy),
1237
- card_context: prefetch_card_context(card_number, repo_path: project_config["repo_path"], agent_name: agent_name),
1238
- agent_name: agent_name)
1239
- end
1282
+ end
1240
1283
 
1241
1284
  pid, log_file = run_agent(prompt, project_config: project_config, chdir: work_dir, log_name: "followup-#{card_number || card_internal_id}", model: model, effort: effort, agent_name: agent_name, card_number: card_number, comment_id: comment_id,
1242
- source: :fizzy, source_context: { card_number: card_number, card_internal_id: card_internal_id, deploy_intent: deploy_intent })
1285
+ source: :fizzy, source_context: { card_number: card_number, card_internal_id: card_internal_id, deploy_intent: deploy_intent }, cli_provider: cli_provider, resume: is_worktree)
1243
1286
  register_session(card_key, pid, log_file: log_file, supersede_key: card_key, agent_name: agent_name)
1244
1287
 
1245
1288
  # Move card to Right Now — agent is actively working again