brainiac 0.0.32 → 0.1.0

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: 484a1d79de556a1d190c22cbafe8db117c24e54f7a051c2a3fb4884b9979f435
4
- data.tar.gz: c170d7271b21aa627f2e591f261628c3abfe731de1cef5c65581e23f4118f46f
3
+ metadata.gz: 82f144b517fd0197684d1912858134c0a6b440263cf2e6bb43d1c4e2651aec3a
4
+ data.tar.gz: 415ae3b9fad9b4d407e13147ce0c28286b8292897175f45c37d39b4dfbf2cccd
5
5
  SHA512:
6
- metadata.gz: 78f90db425eaa92c1b3f4718845ea7449a6e267f86bad4873147d01625bf3759180f8f121521ed0ab70fc9c72563e6c4ca8f367dcf6364e91774623588cff911
7
- data.tar.gz: 9ef305db5eb93589935780ef5a1bcd4063e018d86522a825fb500d3ff0e023ca1e145154196a92c768c934d2dcf9c4a77c4ef67ce729d0e93a537e8c91bf1d1a
6
+ metadata.gz: 567146d407026c606a08a9c3d592059a43f195f9785318d3763ad368aab002d16153a2b442cf778a06513b231327552201f71b1d7bb2680036214f0cf842e5cc
7
+ data.tar.gz: a41c6857915e2e9493f45fa0d49755e4958d6c4de6175a7171d7e25a8013ac3b3f498a7ab1933021c09b0bf3a5768bd1542176d59a0e4ad680e186ce7af74545
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- brainiac (0.0.32)
4
+ brainiac (0.1.0)
5
5
  puma (~> 7.2)
6
6
  rackup (~> 2.3)
7
7
  sinatra (~> 4.1)
@@ -11,22 +11,22 @@ GEM
11
11
  specs:
12
12
  ast (2.4.3)
13
13
  base64 (0.3.0)
14
- json (2.19.9)
15
- language_server-protocol (3.17.0.5)
14
+ json (2.21.2)
15
+ language_server-protocol (3.17.0.6)
16
16
  lint_roller (1.1.0)
17
17
  logger (1.7.0)
18
18
  minitest (5.27.0)
19
19
  mustermann (3.1.1)
20
20
  nio4r (2.7.5)
21
- parallel (2.1.0)
22
- parser (3.3.11.1)
21
+ parallel (2.2.0)
22
+ parser (3.3.12.0)
23
23
  ast (~> 2.4.1)
24
24
  racc
25
25
  prism (1.9.0)
26
26
  puma (7.2.1)
27
27
  nio4r (~> 2.0)
28
28
  racc (1.8.1)
29
- rack (3.2.6)
29
+ rack (3.2.7)
30
30
  rack-protection (4.2.1)
31
31
  base64 (>= 0.1.0)
32
32
  logger (>= 1.6.0)
@@ -50,7 +50,7 @@ GEM
50
50
  rubocop-ast (>= 1.49.0, < 2.0)
51
51
  ruby-progressbar (~> 1.7)
52
52
  unicode-display_width (>= 2.4.0, < 4.0)
53
- rubocop-ast (1.49.1)
53
+ rubocop-ast (1.50.0)
54
54
  parser (>= 3.3.7.2)
55
55
  prism (~> 1.7)
56
56
  rubocop-performance (1.26.1)
@@ -65,7 +65,7 @@ GEM
65
65
  rack-protection (= 4.2.1)
66
66
  rack-session (>= 2.0.0, < 3)
67
67
  tilt (~> 2.0)
68
- tilt (2.7.0)
68
+ tilt (2.9.0)
69
69
  unicode-display_width (3.2.0)
70
70
  unicode-emoji (~> 4.1)
71
71
  unicode-emoji (4.2.0)
@@ -84,20 +84,20 @@ DEPENDENCIES
84
84
  CHECKSUMS
85
85
  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
86
86
  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
87
- brainiac (0.0.32)
88
- json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
89
- language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
87
+ brainiac (0.1.0)
88
+ json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
89
+ language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
90
90
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
91
91
  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
92
92
  minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
93
93
  mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198
94
94
  nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
95
- parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356
96
- parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54
95
+ parallel (2.2.0) sha256=e1059c5fd7b649558a0aec38a769f06a42942bdb40503d005a59c352fe011cd8
96
+ parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828
97
97
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
98
98
  puma (7.2.1) sha256=d7bf0e9cabd532e0d401e142cd94e3ac531e993610e2d80e6fbf9c26961414b0
99
99
  racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
100
- rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2
100
+ rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7
101
101
  rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
102
102
  rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
103
103
  rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
@@ -105,11 +105,11 @@ CHECKSUMS
105
105
  rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
106
106
  regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
107
107
  rubocop (1.88.0) sha256=e420ddf1662d0ef34bc8a2910ac4b396a7ddda0b51a708264405241734b08e0b
108
- rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
108
+ rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db
109
109
  rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
110
110
  ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
111
111
  sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
112
- tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3
112
+ tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5
113
113
  unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
114
114
  unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
115
115
 
data/README.md CHANGED
@@ -1270,6 +1270,32 @@ curl "http://localhost:4567/api/gif?q=excited" # Search for GIFs (r
1270
1270
  curl http://localhost:4567/api/cron # Cron jobs and thread status
1271
1271
  curl http://localhost:4567/api/logs # Read log files
1272
1272
  curl http://localhost:4567/api/status # Active agent sessions (used by monitor)
1273
+ curl "http://localhost:4567/api/sessions/history?limit=25" # Durable session history (survives restarts)
1274
+ ```
1275
+
1276
+ ### Session History
1277
+
1278
+ `/api/status` returns only *live* sessions plus the last 10 finished ones held in memory
1279
+ (wiped on every restart). For durable, restart-surviving history use
1280
+ `GET /api/sessions/history?limit=N`, backed by an append-only JSONL log at
1281
+ `~/.brainiac/session-history.jsonl`.
1282
+
1283
+ Every finished agent session is recorded with timing, identity (agent, CLI, model, source),
1284
+ exit status, and — where the provider exposes it — **session heaviness**: context window
1285
+ usage and credits spent. Heaviness is captured at completion time via a provider-specific
1286
+ probe that is **declared in the provider config**, not hardcoded in brainiac. Add a
1287
+ `heaviness_probe` block to `~/.brainiac/cli-providers/<name>.json`:
1288
+
1289
+ ```json
1290
+ "heaviness_probe": { "type": "kiro_sqlite", "db_path": "~/.local/share/kiro-cli/data.sqlite3" }
1291
+ ```
1292
+
1293
+ `type` selects the probe strategy and `db_path` points at the provider's local usage store,
1294
+ so enabling/disabling a provider or moving its data store is pure JSON. kiro-cli ships with a
1295
+ `kiro_sqlite` probe (reads its local conversation sqlite, read-only). A genuinely new storage
1296
+ mechanism needs a small probe implementation in `lib/brainiac/session_history.rb`; providers
1297
+ with no `heaviness_probe` simply record timing + identity. The file is trimmed to the most
1298
+ recent 500 records.
1273
1299
  ```
1274
1300
 
1275
1301
  ## Development
@@ -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 heaviness_probe].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: {})
@@ -444,6 +507,74 @@ rescue StandardError => e
444
507
  nil
445
508
  end
446
509
 
510
+ # Detect when an agent exited cleanly (exit 0) but produced no usable response.
511
+ # This covers cases the crash path misses because the exit code is 0 — most notably a
512
+ # CLI that prints a request/usage-limit notice and exits without doing any work.
513
+ #
514
+ # Returns a short human-readable reason string when the session yielded no response,
515
+ # or nil when the session did produce output (i.e. nothing to surface).
516
+ #
517
+ # @param log_file [String, nil] Path to the agent's log file
518
+ # @param output_content [String, nil] Structured output captured via --output-last-message, if any
519
+ NO_OUTPUT_LIMIT_PATTERNS = [
520
+ /\bmonthly request limit reached\b/i,
521
+ /\brequest limit reached\b/i,
522
+ /\busage limit\b/i,
523
+ /\brate limit(?:ed|ing)?\b/i,
524
+ /\bquota (?:exceeded|reached)\b/i,
525
+ /\btoo many requests\b/i
526
+ ].freeze
527
+
528
+ def detect_no_output_reason(log_file, output_content)
529
+ # If we captured a structured response, the session clearly produced output.
530
+ return nil if output_content && !output_content.strip.empty?
531
+
532
+ tail = extract_crash_snippet(log_file, max_lines: 40)
533
+ return nil if tail.nil?
534
+
535
+ limit_line = tail.lines.map(&:strip).reverse.find do |line|
536
+ NO_OUTPUT_LIMIT_PATTERNS.any? { |re| line.match?(re) }
537
+ end
538
+ return "hit a request/usage limit: #{limit_line}" if limit_line
539
+
540
+ # No structured output and no recognizable limit notice. Treat a log that carries no
541
+ # assistant text as "produced no response" so the channel isn't left silent. We can't
542
+ # parse every CLI's transcript here, so this is best-effort: only flag when the log is
543
+ # effectively empty of content beyond startup/boilerplate lines.
544
+ meaningful = tail.lines.map(&:strip).reject do |line|
545
+ line.empty? ||
546
+ line.start_with?("---", "▸") ||
547
+ line.match?(/all tools are now trusted/i) ||
548
+ line.match?(/mcp server did not load/i) ||
549
+ line.match?(/agents can sometimes do unexpected things/i) ||
550
+ line.match?(%r{learn more at https?://}i)
551
+ end
552
+ return "produced no response (log contained no agent output)" if meaningful.empty?
553
+
554
+ nil
555
+ end
556
+
557
+ # Surface a clean-exit-but-no-response session to the originating channel. Reuses the
558
+ # crash notification delivery path (the :agent_crashed hook) so plugins deliver it the
559
+ # same way they deliver failures, but frames it as "no response" rather than a crash.
560
+ def notify_agent_no_output(reason:, log_file:, agent_name:, source:, source_context:, project_config:)
561
+ agent_display = agent_name || "Agent"
562
+ snippet = extract_crash_snippet(log_file)
563
+
564
+ handled = Brainiac.emit(:agent_crashed,
565
+ exit_status: 0, log_file: log_file, agent_name: agent_display,
566
+ source: source, source_context: source_context, project_config: project_config,
567
+ snippet: snippet, no_output: true, no_output_reason: reason)
568
+
569
+ if handled.any?
570
+ LOG.info "[NoOutput] #{agent_display} #{reason} — notified via #{source}"
571
+ else
572
+ LOG.warn "[NoOutput] #{agent_display} #{reason} but no plugin handled notification (source: #{source})"
573
+ end
574
+ rescue StandardError => e
575
+ LOG.error "[NoOutput] Unexpected error: #{e.message}"
576
+ end
577
+
447
578
  # Notify the originating channel that an agent crashed.
448
579
  # source: :github, :discord, or plugin-registered sources
449
580
  # source_context: hash with channel-specific info needed to post the notification
@@ -561,11 +692,24 @@ end
561
692
  def resume_viable?(project_config:, cli_provider: nil, agent_name: nil, chdir: nil)
562
693
  resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
563
694
  chdir ||= resolved["repo_path"]
695
+ return stored_cli_session_viable?(resolved, chdir) if resolved["resume_id_flag"]
564
696
  return false unless resolved["resume_flag"] || resolved["resume_args"]
565
697
 
566
698
  prior_session_exists?(chdir, resolved["agent_cli"], session_dir: resolved["session_dir"])
567
699
  end
568
700
 
701
+ # True when this CLI resumes by session id AND the work item has a still-existing id.
702
+ def stored_cli_session_viable?(resolved, chdir)
703
+ session_id = cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir)
704
+ return false if session_id.to_s.empty?
705
+ return true unless resolved["session_list_command"]
706
+
707
+ sessions = list_cli_sessions(resolved["session_list_command"], **session_list_fields(resolved))
708
+ return false unless sessions
709
+
710
+ sessions.any? { |s| s["id"] == session_id }
711
+ end
712
+
569
713
  # Determine whether a session resume should actually happen.
570
714
  # Called by run_agent — plugins should NOT call this directly; pass `resume: true` to run_agent.
571
715
  #
@@ -573,8 +717,19 @@ end
573
717
  # - :resume_args — when the provider uses subcommand-based resume (build_agent_cmd replaces default_args)
574
718
  # - String (the resume flag) — when the provider uses flag-based resume (appended to cmd)
575
719
  # - 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"])
720
+ def resolve_resume(resume, resolved, chdir, session_id: nil)
721
+ return false unless resume
722
+
723
+ if resolved["resume_id_flag"]
724
+ id = session_id.to_s.empty? ? cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir) : session_id
725
+ if id.to_s.empty?
726
+ LOG.info "[Dispatch] Resume requested but no stored #{resolved["agent_cli"]} session for #{chdir} — starting fresh"
727
+ return false
728
+ end
729
+ return { "flag" => resolved["resume_id_flag"], "id" => id }
730
+ end
731
+
732
+ return false unless resolved["resume_flag"] || resolved["resume_args"]
578
733
  if prior_session_exists?(chdir, resolved["agent_cli"], session_dir: resolved["session_dir"])
579
734
  # Return :resume_args when the provider uses subcommand-based resume (e.g. Codex exec resume)
580
735
  return resolved["resume_args"] ? :resume_args : resolved["resume_flag"]
@@ -584,6 +739,16 @@ def resolve_resume(resume, resolved, chdir)
584
739
  false
585
740
  end
586
741
 
742
+ # Some CLIs (e.g. grok --session-id) let the caller name a new session up front.
743
+ # Mint a UUID only on the first run, when we are not resuming an existing id.
744
+ def mint_cli_session_id(resolved, stored_session:, resuming:)
745
+ return nil if resuming
746
+ return nil unless resolved["new_session_id_flag"]
747
+ return nil unless stored_session.to_s.empty?
748
+
749
+ SecureRandom.uuid
750
+ end
751
+
587
752
  # Check if intent detection says to skip dispatching the agent.
588
753
  # Returns true if the message should be skipped, false otherwise.
589
754
  # Only runs if a raw message is provided, an agent is named, and intent is enabled.
@@ -619,8 +784,12 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
619
784
  effort ||= resolved["agent_effort"]
620
785
  agent_config_name = agent_name&.downcase&.gsub(/[^a-z0-9-]/, "-")
621
786
 
787
+ stored_session = cli_session_id_for(agent_cli: resolved["agent_cli"], worktree: chdir)
788
+ work_item_id = find_work_item_by_worktree(chdir)&.first
789
+
622
790
  # 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)
791
+ should_resume = resolve_resume(resume, resolved, chdir, session_id: stored_session)
792
+ minted_session_id = mint_cli_session_id(resolved, stored_session: stored_session, resuming: should_resume)
624
793
 
625
794
  # Pre-dispatch hook — plugins can prep the working directory (e.g., copy config files, clean up)
626
795
  Brainiac.emit(:pre_dispatch, chdir: chdir, project_config: project_config, agent_name: agent_name)
@@ -634,37 +803,31 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
634
803
 
635
804
  cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort,
636
805
  prompt_file: prompt_file, resume: should_resume,
637
- output_file: output_file, chdir: chdir)
638
- prompt_mode = resolved["prompt_mode"] || "stdin"
639
-
806
+ output_file: output_file, chdir: chdir, title: work_item_id,
807
+ new_session_id: minted_session_id)
640
808
  spawn_env = agent_env_for(agent_name).merge(env)
641
809
 
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?
810
+ log_agent_launch(resolved: resolved, chdir: chdir, log_file: log_file, prompt_file: prompt_file,
811
+ output_file: output_file, cmd: cmd, should_resume: should_resume,
812
+ spawn_env: spawn_env, agent_name: agent_name)
645
813
 
646
814
  project_key_for_restart = PROJECTS.find { |_k, v| v == project_config }&.first
647
815
  head_before, status_before = capture_git_state(chdir) if project_key_for_restart == "brainiac"
648
816
 
649
- pid = spawn(spawn_env, *cmd,
650
- chdir: chdir,
651
- **(prompt_mode == "stdin" ? { in: prompt_file } : {}),
652
- out: [log_file, "w"],
653
- err: %i[child out])
654
-
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
817
+ started_at = Time.now
818
+ stdin_redirect = (resolved["prompt_mode"] || "stdin") == "stdin" ? { in: prompt_file } : {}
819
+ pid = spawn(spawn_env, *cmd, chdir: chdir, **stdin_redirect, out: [log_file, "w"], err: %i[child out])
820
+
821
+ spawn_completion_watcher(
822
+ pid: pid, agent_cli: resolved["agent_cli"], agent_config_name: agent_config_name,
823
+ agent_name: agent_name, log_file: log_file, log_name: log_name,
824
+ prompt_file: prompt_file, chdir: chdir, source: source,
825
+ source_context: source_context, project_config: project_config,
826
+ card_number: card_number, skip_column_move: skip_column_move,
827
+ output_file: output_file, resolved: resolved, minted_session_id: minted_session_id,
828
+ head_before: head_before, status_before: status_before,
829
+ project_key_for_restart: project_key_for_restart, model: model, started_at: started_at
830
+ )
668
831
 
669
832
  LOG.info "#{resolved["agent_cli"]} started (pid: #{pid}, agent: #{agent_config_name || "default"}, " \
670
833
  "model: #{model || "default"}), tail -f #{log_file}"
@@ -672,6 +835,28 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
672
835
  [pid, log_file]
673
836
  end
674
837
 
838
+ # Spawn a background thread that waits for the agent process to finish and runs completion handling.
839
+ def spawn_completion_watcher(**ctx)
840
+ thread = Thread.new do
841
+ Process.wait(ctx[:pid])
842
+ handle_agent_completion(**ctx)
843
+ end
844
+ # Without this, an unrescued exception anywhere in handle_agent_completion silently
845
+ # kills the thread and is never logged — which previously hid completion-path failures
846
+ # (e.g. a step raising before session history was archived).
847
+ thread.report_on_exception = true
848
+ thread
849
+ end
850
+
851
+ # Log the details of an agent launch (command, prompt/output files, injected env).
852
+ def log_agent_launch(resolved:, chdir:, log_file:, prompt_file:, output_file:, cmd:, should_resume:, spawn_env:, agent_name:)
853
+ LOG.info "Running #{resolved["agent_cli"]} in #{chdir}, logging to #{log_file}"
854
+ LOG.info "Prompt: #{prompt_file} | Output: #{output_file || "none"} | Command: #{cmd.join(" ")}#{" (resuming session)" if should_resume}"
855
+ return if spawn_env.empty?
856
+
857
+ LOG.info "Injecting #{spawn_env.size} env var(s) for agent #{agent_name}: #{spawn_env.keys.join(", ")}"
858
+ end
859
+
675
860
  # Write agent prompt to a temp file, return path.
676
861
  def write_agent_prompt_file(prompt, log_name, timestamp)
677
862
  prompt_dir = File.join(BRAINIAC_DIR, "tmp")
@@ -717,7 +902,8 @@ end
717
902
  # When chdir is provided and the provider has a cwd_flag, appends it so the CLI
718
903
  # itself switches to the working directory (e.g. `codex -C /path/to/project`).
719
904
  # 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)
905
+ def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, prompt_file: nil, resume: false,
906
+ output_file: nil, chdir: nil, title: nil, new_session_id: nil)
721
907
  cmd = [resolved["agent_cli"]]
722
908
  # cwd_flag: pass the working directory as a CLI argument (e.g. -C for Codex CLI).
723
909
  # This is added early so it appears before subcommands/args (global option).
@@ -730,25 +916,44 @@ def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, p
730
916
  # e.g. "exec --full-auto" becomes "exec resume --last --full-auto"
731
917
  args = resume == :resume_args && resolved["resume_args"] ? resolved["resume_args"] : resolved["agent_cli_args"]
732
918
  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
919
+ append_model_to_cmd(cmd, model, resolved)
743
920
  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)
921
+ cmd.push(resolved["title_flag"], title) if title && resolved["title_flag"]
922
+ if new_session_id && resolved["new_session_id_flag"] && !(resume.is_a?(Hash) || resume.is_a?(String) || resume == :resume_args)
923
+ cmd.push(resolved["new_session_id_flag"], new_session_id)
924
+ end
925
+ append_resume_to_cmd(cmd, resume)
746
926
  # prompt_mode: "flag" passes the prompt file path via the configured prompt_flag (e.g. --prompt-file).
747
927
  cmd.push(resolved["prompt_flag"], prompt_file) if prompt_file && resolved["prompt_mode"] == "flag" && resolved["prompt_flag"]
748
928
  # output_last_message_flag: capture the agent's final message to a file (e.g. codex exec -o <path>).
749
929
  cmd.push(resolved["output_last_message_flag"], output_file) if output_file && resolved["output_last_message_flag"]
750
930
  cmd
751
931
  end
932
+
933
+ # Append the --model flag when a valid model ID is resolved for this provider.
934
+ # "auto" means "let the CLI choose" — skip passing it unless the provider maps it.
935
+ def append_model_to_cmd(cmd, model, resolved)
936
+ return unless model && resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty?
937
+
938
+ allowed = resolved["allowed_models"] || {}
939
+ # If the model is a key in allowed_models, use the mapped value (e.g. "auto" -> "o4-mini").
940
+ # This handles cases where different projects use "auto" but each CLI provider maps it differently.
941
+ effective_model = allowed.key?(model) ? allowed[model] : model
942
+ is_known = allowed.value?(effective_model) || allowed.key?(effective_model)
943
+ cmd.push(resolved["agent_model_flag"], effective_model) if is_known
944
+ end
945
+
946
+ # Resume via --session <id> (OpenCode), a bare flag (grok -c), or resume_args (already applied).
947
+ def append_resume_to_cmd(cmd, resume)
948
+ case resume
949
+ when Hash
950
+ flag = resume["flag"] || resume[:flag]
951
+ id = resume["id"] || resume[:id]
952
+ cmd.push(flag, id) if flag && id
953
+ when String
954
+ cmd.push(resume)
955
+ end
956
+ end
752
957
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
753
958
 
754
959
  # Map a Brainiac effort level through the provider's effort_map (if any).
@@ -778,23 +983,130 @@ def append_effort_to_cmd(cmd, effort, resolved)
778
983
  end
779
984
  end
780
985
 
781
- # Append --model flag if the model is valid for this provider.
986
+ # Parse a CLI session list as JSON, ignoring non-JSON prefixes (e.g. mise).
987
+ # Normalizes OpenCode `{id, directory, updated}` and kiro `{sessionId, cwd, updatedAt}`
988
+ # (including kiro's `[{cwd, sessions: [...]}]` envelopes) into a flat array of
989
+ # `{ "id", "directory", "updated" }` hashes. Provider JSON can override field names.
990
+ def parse_session_list_output(output, id_field: nil, directory_field: nil, updated_field: nil, list_path: nil)
991
+ return nil if output.nil? || output.strip.empty?
992
+
993
+ json_start = output.index("[") || output.index("{")
994
+ return nil unless json_start
995
+
996
+ data = JSON.parse(output[json_start..])
997
+ rows = data.is_a?(Array) ? data : Array(data["sessions"] || data["data"])
998
+ return nil unless rows.is_a?(Array)
999
+
1000
+ flatten_session_rows(rows, id_field: id_field, directory_field: directory_field,
1001
+ updated_field: updated_field, list_path: list_path)
1002
+ rescue JSON::ParserError
1003
+ nil
1004
+ end
1005
+
1006
+ def flatten_session_rows(rows, id_field: nil, directory_field: nil, updated_field: nil, list_path: nil)
1007
+ nested_key = list_path.to_s.empty? ? "sessions" : list_path
1008
+ fields = { id_field: id_field, directory_field: directory_field, updated_field: updated_field }
1009
+ rows.flat_map do |row|
1010
+ next [] unless row.is_a?(Hash)
1011
+
1012
+ nested = row[nested_key]
1013
+ if nested.is_a?(Array)
1014
+ parent_dir = session_value(row, directory_field, %w[directory dir cwd])
1015
+ nested.filter_map { |item| normalize_session_row(item, parent_dir: parent_dir, **fields) }
1016
+ else
1017
+ [normalize_session_row(row, **fields)].compact
1018
+ end
1019
+ end
1020
+ end
1021
+
1022
+ def normalize_session_row(row, id_field: nil, directory_field: nil, updated_field: nil, parent_dir: nil)
1023
+ return nil unless row.is_a?(Hash)
1024
+
1025
+ id = session_value(row, id_field, %w[id sessionId session_id])
1026
+ return nil if id.to_s.empty?
1027
+
1028
+ {
1029
+ "id" => id.to_s,
1030
+ "directory" => session_value(row, directory_field, %w[directory dir cwd]) || parent_dir,
1031
+ "updated" => session_value(row, updated_field, %w[updated time_updated updatedAt updated_at]) || 0
1032
+ }
1033
+ end
1034
+
1035
+ def session_value(row, explicit_field, fallbacks)
1036
+ return row[explicit_field] if explicit_field && !explicit_field.to_s.empty? && row.key?(explicit_field)
1037
+
1038
+ fallbacks.each { |key| return row[key] if row.key?(key) }
1039
+ nil
1040
+ end
1041
+
1042
+ def session_list_fields(resolved)
1043
+ {
1044
+ id_field: resolved["session_id_field"],
1045
+ directory_field: resolved["session_directory_field"],
1046
+ updated_field: resolved["session_updated_field"],
1047
+ list_path: resolved["session_list_path"]
1048
+ }
1049
+ end
1050
+
1051
+ def list_cli_sessions(command, **fields)
1052
+ stdout, stderr, status = Open3.capture3(command)
1053
+ unless status.success?
1054
+ LOG.warn "[Session] session_list_command failed (exit #{status.exitstatus}): #{stderr.strip}"
1055
+ return nil
1056
+ end
1057
+
1058
+ parse_session_list_output(stdout, **fields)
1059
+ end
1060
+
1061
+ # Most recently updated session whose directory matches chdir.
1062
+ # If no session has a directory (CLI already scoped the list to cwd), pick the newest overall.
1063
+ def select_session_id_for_directory(sessions, chdir)
1064
+ return nil if sessions.nil? || sessions.empty?
1065
+
1066
+ with_ids = sessions.select { |session| session.is_a?(Hash) && !session["id"].to_s.empty? }
1067
+ return nil if with_ids.empty?
1068
+
1069
+ if chdir
1070
+ target = path_for_compare(chdir)
1071
+ matching = with_ids.select { |session| session["directory"] && path_for_compare(session["directory"]) == target }
1072
+ with_ids = matching unless matching.empty?
1073
+ end
1074
+
1075
+ newest = with_ids.max_by { |session| session["updated"] || 0 }
1076
+ newest && newest["id"]
1077
+ end
1078
+
1079
+ # After a run, remember the CLI session id on the work item so later dispatches
1080
+ # can pass --session/--resume-id <id> instead of continuing whichever session was last globally.
1081
+ def capture_cli_session_id(resolved:, chdir:, minted_session_id: nil)
1082
+ if minted_session_id
1083
+ update_work_item_cli_session(agent_cli: resolved["agent_cli"], session_id: minted_session_id, worktree: chdir)
1084
+ return
1085
+ end
1086
+ return unless resolved["session_list_command"] && resolved["resume_id_flag"]
1087
+
1088
+ sessions = list_cli_sessions(resolved["session_list_command"], **session_list_fields(resolved))
1089
+ session_id = select_session_id_for_directory(sessions, chdir)
1090
+ unless session_id
1091
+ LOG.info "[Session] No CLI session found for #{chdir}"
1092
+ return
1093
+ end
1094
+
1095
+ update_work_item_cli_session(agent_cli: resolved["agent_cli"], session_id: session_id, worktree: chdir)
1096
+ end
1097
+
782
1098
  def handle_agent_completion(**ctx)
783
1099
  agent_exit_status = $CHILD_STATUS.exitstatus
784
1100
  agent_signaled = $CHILD_STATUS.signaled?
785
1101
  LOG.info "#{ctx[:agent_cli]} finished (pid: #{ctx[:pid]}, exit: #{agent_exit_status})"
786
1102
 
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
1103
+ notify_crash_if_needed(ctx, agent_exit_status, agent_signaled)
794
1104
 
795
1105
  # Read structured output if the provider wrote to an output file (--output-last-message).
796
1106
  output_content = read_output_file(ctx[:output_file])
797
1107
 
1108
+ capture_session_id_if_possible(ctx)
1109
+
798
1110
  # Emit lifecycle hook — plugins handle post-session actions (e.g., plugin moves card, appends footer)
799
1111
  Brainiac.emit(:agent_completed,
800
1112
  card_number: ctx[:card_number] || ctx[:source_context]&.dig(:card_number),
@@ -813,22 +1125,96 @@ def handle_agent_completion(**ctx)
813
1125
  # Clean up the output file after hook emission (content already captured above).
814
1126
  FileUtils.rm_f(ctx[:output_file]) if ctx[:output_file]
815
1127
 
1128
+ run_post_completion_steps(ctx, exit_status: agent_exit_status, signaled: agent_signaled, output_content: output_content)
1129
+ end
1130
+
1131
+ # Independent, best-effort steps that run after the lifecycle hook. Each is isolated via
1132
+ # completion_step so a failure in one (e.g. a raising notify path) can't abort the rest —
1133
+ # notably session-history archival, which must always run regardless of earlier steps.
1134
+ def run_post_completion_steps(ctx, exit_status:, signaled:, output_content:)
1135
+ # Clean exit (0) but no usable response — e.g. the CLI hit a request/usage limit and
1136
+ # bailed without doing work. The crash path only fires on non-zero exits, so this would
1137
+ # otherwise be silent. Surface it to the originating channel.
1138
+ completion_step("notify_no_output") do
1139
+ notify_no_output_if_needed(ctx, exit_status: exit_status, signaled: signaled, output_content: output_content)
1140
+ end
1141
+
1142
+ # Durable session history — record every finished session to disk (survives restarts),
1143
+ # including provider-specific "heaviness" (context window usage + credits). Best-effort.
1144
+ completion_step("archive_session_history") do
1145
+ archive_session_history(ctx: ctx, exit_status: exit_status, signaled: signaled)
1146
+ end
1147
+
1148
+ completion_step("qmd_update") { run_qmd_update(ctx[:agent_config_name]) }
1149
+ completion_step("skill_candidate") { log_skill_candidate(ctx[:log_file]) }
1150
+ completion_step("brain_push") { brain_push(message: "#{ctx[:agent_config_name] || "agent"}: #{ctx[:log_name]}") }
1151
+ # check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
1152
+ end
1153
+
1154
+ # Run an independent post-completion step, logging and swallowing any failure so one
1155
+ # broken step (e.g. a plugin-facing call raising) can't abort the remaining steps —
1156
+ # notably session-history archival, which must not depend on earlier steps succeeding.
1157
+ def completion_step(name)
1158
+ yield
1159
+ rescue StandardError => e
1160
+ LOG.error "[Completion] step '#{name}' failed: #{e.class}: #{e.message}"
1161
+ LOG.error "[Completion] #{e.backtrace.first(3).join("\n ")}" if e.backtrace
1162
+ nil
1163
+ end
1164
+
1165
+ # Surface a clean-exit session that produced no response, pulling fields from the
1166
+ # completion context. Only fires for exit 0 (non-zero already goes through the crash
1167
+ # path) with a source to notify. Extracted from handle_agent_completion.
1168
+ def notify_no_output_if_needed(ctx, exit_status:, signaled:, output_content:)
1169
+ return unless ctx[:source]
1170
+ return if signaled
1171
+ return unless exit_status&.zero?
1172
+
1173
+ reason = detect_no_output_reason(ctx[:log_file], output_content)
1174
+ return unless reason
1175
+
1176
+ notify_agent_no_output(
1177
+ reason: reason, log_file: ctx[:log_file], agent_name: ctx[:agent_name],
1178
+ source: ctx[:source], source_context: ctx[:source_context], project_config: ctx[:project_config]
1179
+ )
1180
+ end
1181
+
1182
+ # Capture the CLI session id after a run, if the provider and working dir are known.
1183
+ def capture_session_id_if_possible(ctx)
1184
+ return unless ctx[:resolved] && ctx[:chdir]
1185
+
1186
+ capture_cli_session_id(resolved: ctx[:resolved], chdir: ctx[:chdir], minted_session_id: ctx[:minted_session_id])
1187
+ end
1188
+
1189
+ # Notify plugins of a crashed agent session (non-zero exit, not signaled).
1190
+ def notify_crash_if_needed(ctx, agent_exit_status, agent_signaled)
1191
+ return unless ctx[:source] && agent_exit_status && agent_exit_status != 0 && !agent_signaled
1192
+
1193
+ notify_agent_crash(
1194
+ exit_status: agent_exit_status, log_file: ctx[:log_file],
1195
+ agent_name: ctx[:agent_name], source: ctx[:source], source_context: ctx[:source_context],
1196
+ project_config: ctx[:project_config]
1197
+ )
1198
+ end
1199
+
1200
+ # Run `qmd update` to reindex the brain after a session, logging the outcome.
1201
+ def run_qmd_update(agent_config_name)
816
1202
  qmd_out, qmd_status = Open3.capture2e("qmd", "update")
817
1203
  if qmd_status.success?
818
- LOG.info "[Brain] qmd update completed after #{ctx[:agent_config_name] || "agent"} session"
1204
+ LOG.info "[Brain] qmd update completed after #{agent_config_name || "agent"} session"
819
1205
  else
820
1206
  LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}"
821
1207
  end
1208
+ end
822
1209
 
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
1210
+ # Log whether the session qualifies for skill extraction (agent nudged via reflection prompt).
1211
+ def log_skill_candidate(log_file)
1212
+ skill_candidate = detect_skill_candidate(log_file)
1213
+ return unless skill_candidate[:extract]
829
1214
 
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])
1215
+ LOG.info "[Skills] Session qualifies for skill extraction " \
1216
+ "(#{skill_candidate[:tool_calls]} tool calls, #{skill_candidate[:error_patterns]} error patterns) " \
1217
+ "— agent was nudged via reflection prompt"
832
1218
  end
833
1219
 
834
1220
  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
@@ -217,6 +265,13 @@ get "/api/status" do
217
265
  end
218
266
  end
219
267
 
268
+ get "/api/sessions/history" do
269
+ content_type :json
270
+ limit = (params["limit"] || 50).to_i.clamp(1, SESSION_HISTORY_MAX)
271
+ history = read_session_history(limit: limit)
272
+ { history: history, count: history.size }.to_json
273
+ end
274
+
220
275
  post "/api/sessions/kill/:card_key" do
221
276
  content_type :json
222
277
  card_key = params[:card_key]
@@ -255,9 +310,7 @@ get "/api/logs" do
255
310
  halt 400, "Invalid path" if log_file.include?("..") || !log_file.start_with?("/")
256
311
  halt 404, "File not found" unless File.exist?(log_file)
257
312
 
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) }
313
+ halt 403, "Forbidden" unless log_file_allowed?(log_file)
261
314
 
262
315
  all_lines = File.readlines(log_file).last(lines)
263
316
  all_lines.join.gsub(/\e\[[\d;]*[a-zA-Z]/, "").gsub(/\e\[\?[\d;]*[a-zA-Z]/, "")
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ # Durable session history + "session heaviness" tracking.
6
+ #
7
+ # The in-memory RECENT_SESSIONS (see sessions.rb) only holds the last 10 finished
8
+ # sessions and is wiped on every brainiac restart. This module persists a durable,
9
+ # append-only record of every completed agent session to disk so the monitor (and
10
+ # anything else) can review real history: how long a session ran, which CLI/model it
11
+ # used, where it came from, and — where the provider exposes it — how "heavy" the
12
+ # session was (context window usage + credits spent).
13
+ #
14
+ # Storage: append-only JSONL at ~/.brainiac/session-history.jsonl (one record per line).
15
+ # JSONL is chosen deliberately: appends are atomic-ish and cheap, the file survives
16
+ # restarts, it's trivially tailable, and a corrupt line never poisons the whole file.
17
+ # The file is trimmed to SESSION_HISTORY_MAX records to bound growth.
18
+
19
+ SESSION_HISTORY_FILE = File.join(BRAINIAC_DIR, "session-history.jsonl")
20
+ SESSION_HISTORY_MAX = 500
21
+ SESSION_HISTORY_MUTEX = Mutex.new
22
+
23
+ # Append a completed-session record to the durable history file.
24
+ # `record` is a plain Hash; symbol keys are fine (serialized to JSON).
25
+ # Best-effort: any failure is logged and swallowed — history is never allowed to
26
+ # break the completion path.
27
+ def record_session_history(record)
28
+ SESSION_HISTORY_MUTEX.synchronize do
29
+ FileUtils.mkdir_p(File.dirname(SESSION_HISTORY_FILE))
30
+ File.open(SESSION_HISTORY_FILE, "a") { |f| f.puts(JSON.generate(record)) }
31
+ trim_session_history!
32
+ end
33
+ LOG.info "[SessionHistory] Recorded session for #{record["agent"] || "?"} → #{SESSION_HISTORY_FILE}"
34
+ true
35
+ rescue StandardError => e
36
+ LOG.warn "[SessionHistory] Failed to record session: #{e.class}: #{e.message}"
37
+ false
38
+ end
39
+
40
+ # Read the most recent `limit` session records, newest first.
41
+ def read_session_history(limit: 50)
42
+ return [] unless File.exist?(SESSION_HISTORY_FILE)
43
+
44
+ lines = SESSION_HISTORY_MUTEX.synchronize { File.readlines(SESSION_HISTORY_FILE) }
45
+ records = lines.filter_map do |line|
46
+ line = line.strip
47
+ next if line.empty?
48
+
49
+ begin
50
+ JSON.parse(line)
51
+ rescue JSON::ParserError
52
+ nil
53
+ end
54
+ end
55
+ records.last(limit).reverse
56
+ rescue StandardError => e
57
+ LOG.warn "[SessionHistory] Failed to read history: #{e.message}"
58
+ []
59
+ end
60
+
61
+ # Keep the history file bounded. Call inside SESSION_HISTORY_MUTEX.
62
+ def trim_session_history!
63
+ lines = File.readlines(SESSION_HISTORY_FILE)
64
+ return if lines.size <= SESSION_HISTORY_MAX
65
+
66
+ File.write(SESSION_HISTORY_FILE, lines.last(SESSION_HISTORY_MAX).join)
67
+ rescue StandardError => e
68
+ LOG.warn "[SessionHistory] Failed to trim history: #{e.message}"
69
+ end
70
+
71
+ # Build the durable history record for a finished session and append it.
72
+ # Called from handle_agent_completion. Gathers timing, identity, and — via the
73
+ # provider-specific heaviness probe — context/credit usage. Never raises.
74
+ def archive_session_history(ctx:, exit_status:, signaled:)
75
+ started_at = ctx[:started_at]
76
+ finished_at = Time.now
77
+ heaviness = session_heaviness(resolved: ctx[:resolved], chdir: ctx[:chdir])
78
+ source_context = ctx[:source_context] || {}
79
+ card_key = ctx[:card_key] || source_context[:card_key]
80
+ channel_id = ctx[:channel_id] || source_context[:channel_id]
81
+
82
+ record = {
83
+ "recorded_at" => finished_at.utc.iso8601,
84
+ "agent" => ctx[:agent_name] || ctx[:agent_config_name] || "Unknown",
85
+ "card_key" => card_key,
86
+ "card_number" => ctx[:card_number] || source_context[:card_number],
87
+ "source" => ctx[:source]&.to_s,
88
+ "channel_id" => channel_id,
89
+ "log_file" => ctx[:log_file],
90
+ "cli" => ctx[:agent_cli],
91
+ "model" => ctx[:model],
92
+ "started_at" => started_at&.utc&.iso8601,
93
+ "finished_at" => finished_at.utc.iso8601,
94
+ "duration_seconds" => started_at ? (finished_at - started_at).to_i : nil,
95
+ "exit_status" => exit_status,
96
+ "signaled" => signaled
97
+ }
98
+ record.merge!(heaviness) if heaviness
99
+ record.compact!
100
+
101
+ record_session_history(record)
102
+ end
103
+
104
+ # --- Provider-generic session heaviness probe ---
105
+ #
106
+ # "How heavy was this session?" — context window usage + credits spent.
107
+ # This is inherently provider-specific: each CLI stores (or doesn't store) usage
108
+ # differently. Rather than hardcode any one CLI in this file, brainiac reads a
109
+ # `heaviness_probe` block from the provider's cli-providers/<name>.json. That config
110
+ # declares WHICH probe strategy to use and WHERE its data lives, e.g.:
111
+ #
112
+ # "heaviness_probe": { "type": "kiro_sqlite", "db_path": "~/.local/share/kiro-cli/data.sqlite3" }
113
+ #
114
+ # The config controls the "what and where" (enable/disable a provider, point it at a
115
+ # moved db) with zero code changes. The "how" for each probe TYPE lives here in Ruby —
116
+ # a genuinely new storage mechanism (a provider that logs usage to JSON, an HTTP
117
+ # endpoint, etc.) still needs a small probe implementation, but pointing kiro at a new
118
+ # path or turning a provider's probe on/off is pure JSON.
119
+ #
120
+ # Providers with no `heaviness_probe` (or an unknown type) simply yield nil and the
121
+ # history record carries timing/identity only.
122
+ #
123
+ # Returns a Hash of string-keyed heaviness fields, or nil if unavailable:
124
+ # context_window_tokens, context_usage_pct, context_used_tokens, credits_used
125
+ def session_heaviness(resolved:, chdir:)
126
+ return nil unless resolved && chdir
127
+
128
+ probe = resolved["heaviness_probe"]
129
+ return nil unless probe.is_a?(Hash)
130
+
131
+ case probe["type"]
132
+ when "kiro_sqlite"
133
+ kiro_session_heaviness(chdir, db_path: probe["db_path"])
134
+ end
135
+ rescue StandardError => e
136
+ LOG.warn "[SessionHistory] Heaviness probe failed for #{chdir}: #{e.message}"
137
+ nil
138
+ end
139
+
140
+ # kiro-cli persists every conversation in a local sqlite keyed by working directory.
141
+ # Each conversation's JSON carries the model's context window, the latest request's
142
+ # context-usage percentage, and per-request credit usage. We read it read-only via the
143
+ # sqlite3 CLI (no gem dependency) and derive the heaviness fields. Best-effort: any
144
+ # problem (missing db, no row for this cwd, malformed json) yields nil.
145
+ #
146
+ # The db location comes from the provider's heaviness_probe.db_path so it's not baked
147
+ # into brainiac; KIRO_DEFAULT_DB_PATH is only the fallback.
148
+ KIRO_DEFAULT_DB_PATH = File.join(Dir.home, ".local", "share", "kiro-cli", "data.sqlite3")
149
+
150
+ def kiro_session_heaviness(chdir, db_path: nil)
151
+ db = File.expand_path(db_path || KIRO_DEFAULT_DB_PATH)
152
+ return nil unless File.exist?(db)
153
+
154
+ key = File.expand_path(chdir)
155
+ # The CTE picks the most recently updated conversation for this working directory,
156
+ # then we pull the model's context window, the latest request's context-usage %, and
157
+ # the summed credit spend. `.timeout` (via -cmd, so it doesn't emit output) keeps us
158
+ # from hanging if kiro-cli is mid-write.
159
+ sql = <<~SQL.gsub(/\s+/, " ").strip
160
+ WITH latest AS (
161
+ SELECT value FROM conversations_v2
162
+ WHERE key = #{sqlite_quote(key)} ORDER BY updated_at DESC LIMIT 1
163
+ )
164
+ SELECT
165
+ json_extract(value, '$.model_info.context_window_tokens'),
166
+ (SELECT r.value ->> 'context_usage_percentage'
167
+ FROM latest, json_each(json_extract(latest.value, '$.user_turn_metadata.requests')) r
168
+ ORDER BY CAST(r.key AS INTEGER) DESC LIMIT 1),
169
+ (SELECT ROUND(SUM(CAST(u.value ->> 'value' AS REAL)), 4)
170
+ FROM latest, json_each(json_extract(latest.value, '$.user_turn_metadata.usage_info')) u
171
+ WHERE (u.value ->> 'unit') = 'credit')
172
+ FROM latest;
173
+ SQL
174
+
175
+ out = ""
176
+ status = nil
177
+ Timeout.timeout(6) do
178
+ out, status = Open3.capture2(
179
+ "sqlite3", "-separator", "\t", "-cmd", ".timeout 3000",
180
+ "file:#{db}?mode=ro", sql
181
+ )
182
+ end
183
+ return nil unless status&.success?
184
+
185
+ row = out.strip
186
+ return nil if row.empty?
187
+
188
+ window, pct, credits = row.split("\t", 3)
189
+ window = window.to_i
190
+ return nil if window.zero?
191
+
192
+ pct = pct.to_f
193
+ fields = { "context_window_tokens" => window, "context_usage_pct" => pct.round(2) }
194
+ fields["context_used_tokens"] = (window * pct / 100.0).round if pct.positive?
195
+ fields["credits_used"] = credits.to_f.round(4) if credits && !credits.empty?
196
+ fields
197
+ end
198
+
199
+ # Single-quote a string for safe literal interpolation into SQLite SQL.
200
+ def sqlite_quote(str)
201
+ "'#{str.to_s.gsub("'", "''")}'"
202
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.0.32"
5
+ VERSION = "0.1.0"
6
6
  end
data/lib/brainiac.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "brainiac/agents"
5
5
  require_relative "brainiac/brain"
6
6
  require_relative "brainiac/skills"
7
7
  require_relative "brainiac/sessions"
8
+ require_relative "brainiac/session_history"
8
9
  require_relative "brainiac/prompts"
9
10
  require_relative "brainiac/helpers"
10
11
  require_relative "brainiac/intent"
data/receiver.rb CHANGED
@@ -19,6 +19,7 @@ require_relative "lib/brainiac/agents"
19
19
  require_relative "lib/brainiac/brain"
20
20
  require_relative "lib/brainiac/skills"
21
21
  require_relative "lib/brainiac/sessions"
22
+ require_relative "lib/brainiac/session_history"
22
23
  require_relative "lib/brainiac/prompts"
23
24
  require_relative "lib/brainiac/helpers"
24
25
  require_relative "lib/brainiac/notifications"
@@ -100,6 +101,16 @@ configure do
100
101
  end
101
102
 
102
103
  LOG.info "[Brainiac] Starting v#{BRAINIAC_VERSION} on port #{settings.port} (#{settings.environment})"
104
+ LOG.info "[Brainiac] Server root: #{SERVER_ROOT} (pid #{Process.pid})"
105
+
106
+ # Warn loudly when the server is running from an installed gem rather than a git
107
+ # checkout. During development this is almost always a mistake: `brainiac` on PATH
108
+ # resolves to the gem's bin, so edits to a working checkout never take effect. Surfacing
109
+ # it here turns a long "why won't my change apply" hunt into a one-line startup notice.
110
+ if SERVER_ROOT.include?("/gems/")
111
+ LOG.warn "[Brainiac] Running from an installed GEM (#{SERVER_ROOT}) — not a git checkout. " \
112
+ "Local code changes will NOT be loaded. Run `ruby ./bin/brainiac server` from your checkout to test edits."
113
+ end
103
114
  if intent_config["enabled"]
104
115
  LOG.info "[Intent] Enabled — model: #{intent_config["model"]}, endpoint: #{intent_config["endpoint"]}"
105
116
  begin
@@ -18,5 +18,10 @@
18
18
  "qwen": "qwen3-coder-next",
19
19
  "auto": "auto"
20
20
  },
21
- "efforts": ["low", "medium", "high", "xhigh", "max"]
21
+ "efforts": ["low", "medium", "high", "xhigh", "max"],
22
+ "heaviness_probe": {
23
+ "comment": "Optional. Lets brainiac record how 'heavy' a finished session was (context window usage + credits) in the durable session history. type selects the probe strategy; db_path points at the provider's local usage store. Omit this block to record timing/identity only.",
24
+ "type": "kiro_sqlite",
25
+ "db_path": "~/.local/share/kiro-cli/data.sqlite3"
26
+ }
22
27
  }
@@ -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.32
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis
@@ -142,6 +142,7 @@ files:
142
142
  - lib/brainiac/prompts.rb
143
143
  - lib/brainiac/restart.rb
144
144
  - lib/brainiac/routes/api.rb
145
+ - lib/brainiac/session_history.rb
145
146
  - lib/brainiac/sessions.rb
146
147
  - lib/brainiac/skills.rb
147
148
  - lib/brainiac/users.rb
@@ -166,6 +167,7 @@ files:
166
167
  - templates/cli-providers/gemini.json.example
167
168
  - templates/cli-providers/grok.json.example
168
169
  - templates/cli-providers/kiro.json.example
170
+ - templates/cli-providers/opencode.json.example
169
171
  - templates/hooks/pre-commit
170
172
  - templates/plugins.json.example
171
173
  - templates/roles/code-reviewer.md.example