console_agent 0.2.0 → 0.4.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.
@@ -15,17 +15,44 @@ module ConsoleAgent
15
15
  end
16
16
 
17
17
  def one_shot(query)
18
- result = send_query(query)
19
- track_usage(result)
20
- code = @executor.display_response(result.text)
21
- display_usage(result)
22
- return nil if code.nil? || code.strip.empty?
23
-
24
- if ConsoleAgent.configuration.auto_execute
25
- @executor.execute(code)
26
- else
27
- @executor.confirm_and_execute(code)
18
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
+ console_capture = StringIO.new
20
+ exec_result = with_console_capture(console_capture) do
21
+ result, _ = send_query(query)
22
+ track_usage(result)
23
+ code = @executor.display_response(result.text)
24
+ display_usage(result)
25
+
26
+ exec_result = nil
27
+ executed = false
28
+ has_code = code && !code.strip.empty?
29
+
30
+ if has_code
31
+ exec_result = if ConsoleAgent.configuration.auto_execute
32
+ @executor.execute(code)
33
+ else
34
+ @executor.confirm_and_execute(code)
35
+ end
36
+ executed = !@executor.last_cancelled?
37
+ end
38
+
39
+ @_last_log_attrs = {
40
+ query: query,
41
+ conversation: [{ role: :user, content: query }, { role: :assistant, content: result.text }],
42
+ mode: 'one_shot',
43
+ code_executed: has_code ? code : nil,
44
+ code_output: executed ? @executor.last_output : nil,
45
+ code_result: executed && exec_result ? exec_result.inspect : nil,
46
+ executed: executed,
47
+ start_time: start_time
48
+ }
49
+
50
+ exec_result
28
51
  end
52
+
53
+ log_session(@_last_log_attrs.merge(console_output: console_capture.string))
54
+
55
+ exec_result
29
56
  rescue Providers::ProviderError => e
30
57
  $stderr.puts "\e[31mConsoleAgent Error: #{e.message}\e[0m"
31
58
  nil
@@ -35,10 +62,25 @@ module ConsoleAgent
35
62
  end
36
63
 
37
64
  def explain(query)
38
- result = send_query(query)
39
- track_usage(result)
40
- @executor.display_response(result.text)
41
- display_usage(result)
65
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ console_capture = StringIO.new
67
+ with_console_capture(console_capture) do
68
+ result, _ = send_query(query)
69
+ track_usage(result)
70
+ @executor.display_response(result.text)
71
+ display_usage(result)
72
+
73
+ @_last_log_attrs = {
74
+ query: query,
75
+ conversation: [{ role: :user, content: query }, { role: :assistant, content: result.text }],
76
+ mode: 'explain',
77
+ executed: false,
78
+ start_time: start_time
79
+ }
80
+ end
81
+
82
+ log_session(@_last_log_attrs.merge(console_output: console_capture.string))
83
+
42
84
  nil
43
85
  rescue Providers::ProviderError => e
44
86
  $stderr.puts "\e[31mConsoleAgent Error: #{e.message}\e[0m"
@@ -49,29 +91,132 @@ module ConsoleAgent
49
91
  end
50
92
 
51
93
  def interactive
52
- $stdout.puts "\e[36mConsoleAgent interactive mode. Type 'exit' or 'quit' to leave.\e[0m"
94
+ init_interactive_state
95
+ interactive_loop
96
+ end
97
+
98
+ def resume(session)
99
+ init_interactive_state
100
+
101
+ # Restore state from the previous session
102
+ @history = JSON.parse(session.conversation, symbolize_names: true)
103
+ @interactive_session_id = session.id
104
+ @interactive_query = session.query
105
+ @interactive_session_name = session.name
106
+ @total_input_tokens = session.input_tokens || 0
107
+ @total_output_tokens = session.output_tokens || 0
108
+
109
+ # Seed the capture buffer with previous output so it's preserved on save
110
+ @interactive_console_capture.write(session.console_output.to_s)
111
+
112
+ # Replay to the user via the real stdout (bypass TeeIO to avoid double-capture)
113
+ if session.console_output && !session.console_output.strip.empty?
114
+ @interactive_old_stdout.puts "\e[2m--- Replaying previous session output ---\e[0m"
115
+ @interactive_old_stdout.puts session.console_output
116
+ @interactive_old_stdout.puts "\e[2m--- End of previous output ---\e[0m"
117
+ @interactive_old_stdout.puts
118
+ end
119
+
120
+ interactive_loop
121
+ end
122
+
123
+ private
124
+
125
+ def init_interactive_state
126
+ @interactive_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
127
+ @interactive_console_capture = StringIO.new
128
+ @interactive_old_stdout = $stdout
129
+ $stdout = TeeIO.new(@interactive_old_stdout, @interactive_console_capture)
130
+ @executor.on_prompt = -> { log_interactive_turn }
131
+
53
132
  @history = []
54
133
  @total_input_tokens = 0
55
134
  @total_output_tokens = 0
135
+ @interactive_query = nil
136
+ @interactive_session_id = nil
137
+ @interactive_session_name = nil
138
+ @last_interactive_code = nil
139
+ @last_interactive_output = nil
140
+ @last_interactive_result = nil
141
+ @last_interactive_executed = false
142
+ end
143
+
144
+ def interactive_loop
145
+ auto = ConsoleAgent.configuration.auto_execute
146
+ name_display = @interactive_session_name ? " (#{@interactive_session_name})" : ""
147
+ # Write banner to real stdout (bypass TeeIO) so it doesn't accumulate on resume
148
+ @interactive_old_stdout.puts "\e[36mConsoleAgent interactive mode#{name_display}. Type 'exit' or 'quit' to leave.\e[0m"
149
+ @interactive_old_stdout.puts "\e[2m Auto-execute: #{auto ? 'ON' : 'OFF'} (Shift-Tab or /auto to toggle) | /usage | /name <label>\e[0m"
150
+
151
+ # Bind Shift-Tab to insert /auto command and submit
152
+ if Readline.respond_to?(:parse_and_bind)
153
+ Readline.parse_and_bind('"\e[Z": "\C-a\C-k/auto\C-m"')
154
+ end
56
155
 
57
156
  loop do
58
- input = Readline.readline("\e[33mai> \e[0m", false)
157
+ input = Readline.readline("\001\e[33m\002ai> \001\e[0m\002", false)
59
158
  break if input.nil? # Ctrl-D
60
159
 
61
160
  input = input.strip
62
161
  break if input.downcase == 'exit' || input.downcase == 'quit'
63
162
  next if input.empty?
64
163
 
164
+ if input == '/auto'
165
+ ConsoleAgent.configuration.auto_execute = !ConsoleAgent.configuration.auto_execute
166
+ mode = ConsoleAgent.configuration.auto_execute ? 'ON' : 'OFF'
167
+ @interactive_old_stdout.puts "\e[36m Auto-execute: #{mode}\e[0m"
168
+ next
169
+ end
170
+
171
+ if input == '/usage'
172
+ display_session_summary
173
+ next
174
+ end
175
+
176
+ if input == '/debug'
177
+ ConsoleAgent.configuration.debug = !ConsoleAgent.configuration.debug
178
+ mode = ConsoleAgent.configuration.debug ? 'ON' : 'OFF'
179
+ @interactive_old_stdout.puts "\e[36m Debug: #{mode}\e[0m"
180
+ next
181
+ end
182
+
183
+ if input.start_with?('/name')
184
+ name = input.sub('/name', '').strip
185
+ if name.empty?
186
+ if @interactive_session_name
187
+ @interactive_old_stdout.puts "\e[36m Session name: #{@interactive_session_name}\e[0m"
188
+ else
189
+ @interactive_old_stdout.puts "\e[33m Usage: /name <label> (e.g. /name salesforce_user_123)\e[0m"
190
+ end
191
+ else
192
+ @interactive_session_name = name
193
+ if @interactive_session_id
194
+ require 'console_agent/session_logger'
195
+ SessionLogger.update(@interactive_session_id, name: name)
196
+ end
197
+ @interactive_old_stdout.puts "\e[36m Session named: #{name}\e[0m"
198
+ end
199
+ next
200
+ end
201
+
65
202
  # Add to Readline history (avoid consecutive duplicates)
66
203
  Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
67
204
 
205
+ @interactive_query ||= input
68
206
  @history << { role: :user, content: input }
69
207
 
208
+ # Log the user's prompt line to the console capture (Readline doesn't go through $stdout)
209
+ @interactive_console_capture.write("ai> #{input}\n")
210
+
211
+ # Save immediately so the session is visible in the admin UI while the AI thinks
212
+ log_interactive_turn
213
+
70
214
  begin
71
- result = send_query(input, conversation: @history)
215
+ result, tool_messages = send_query(input, conversation: @history)
72
216
  rescue Interrupt
73
217
  $stdout.puts "\n\e[33m Aborted.\e[0m"
74
218
  @history.pop # Remove the user message that never got a response
219
+ log_interactive_turn
75
220
  next
76
221
  end
77
222
 
@@ -79,6 +224,11 @@ module ConsoleAgent
79
224
  code = @executor.display_response(result.text)
80
225
  display_usage(result, show_session: true)
81
226
 
227
+ # Save after response is displayed so viewer shows progress before Execute prompt
228
+ log_interactive_turn
229
+
230
+ # Add tool call/result messages so the LLM remembers what it learned
231
+ @history.concat(tool_messages) if tool_messages && !tool_messages.empty?
82
232
  @history << { role: :assistant, content: result.text }
83
233
 
84
234
  if code && !code.strip.empty?
@@ -88,6 +238,13 @@ module ConsoleAgent
88
238
  exec_result = @executor.confirm_and_execute(code)
89
239
  end
90
240
 
241
+ unless @executor.last_cancelled?
242
+ @last_interactive_code = code
243
+ @last_interactive_output = @executor.last_output
244
+ @last_interactive_result = exec_result ? exec_result.inspect : nil
245
+ @last_interactive_executed = true
246
+ end
247
+
91
248
  if @executor.last_cancelled?
92
249
  @history << { role: :user, content: "User declined to execute the code." }
93
250
  else
@@ -110,21 +267,28 @@ module ConsoleAgent
110
267
  end
111
268
  end
112
269
  end
270
+
271
+ # Update with the AI response, tokens, and any execution results
272
+ log_interactive_turn
113
273
  end
114
274
 
115
- display_session_summary
116
- $stdout.puts "\e[36mLeft ConsoleAgent interactive mode.\e[0m"
275
+ $stdout = @interactive_old_stdout
276
+ @executor.on_prompt = nil
277
+ finish_interactive_session
278
+ display_exit_info
117
279
  rescue Interrupt
118
280
  # Ctrl-C during Readline input — exit cleanly
281
+ $stdout = @interactive_old_stdout if @interactive_old_stdout
282
+ @executor.on_prompt = nil
119
283
  $stdout.puts
120
- display_session_summary
121
- $stdout.puts "\e[36mLeft ConsoleAgent interactive mode.\e[0m"
284
+ finish_interactive_session
285
+ display_exit_info
122
286
  rescue => e
287
+ $stdout = @interactive_old_stdout if @interactive_old_stdout
288
+ @executor.on_prompt = nil
123
289
  $stderr.puts "\e[31mConsoleAgent Error: #{e.class}: #{e.message}\e[0m"
124
290
  end
125
291
 
126
- private
127
-
128
292
  def provider
129
293
  @provider ||= Providers.build
130
294
  end
@@ -141,25 +305,22 @@ module ConsoleAgent
141
305
  ConsoleAgent.configuration.validate!
142
306
 
143
307
  messages = if conversation
144
- conversation.map { |m| { role: m[:role], content: m[:content] } }
308
+ conversation.dup
145
309
  else
146
310
  [{ role: :user, content: query }]
147
311
  end
148
312
 
149
- if ConsoleAgent.configuration.context_mode == :smart
150
- send_query_with_tools(messages)
151
- else
152
- provider.chat(messages, system_prompt: context)
153
- end
313
+ send_query_with_tools(messages)
154
314
  end
155
315
 
156
316
  def send_query_with_tools(messages)
157
317
  require 'console_agent/tools/registry'
158
- tools = Tools::Registry.new
318
+ tools = Tools::Registry.new(executor: @executor)
159
319
  max_rounds = ConsoleAgent.configuration.max_tool_rounds
160
320
  total_input = 0
161
321
  total_output = 0
162
322
  result = nil
323
+ new_messages = [] # Track messages added during tool use
163
324
 
164
325
  exhausted = false
165
326
 
@@ -168,7 +329,20 @@ module ConsoleAgent
168
329
  $stdout.puts "\e[2m Thinking...\e[0m"
169
330
  end
170
331
 
171
- result = provider.chat_with_tools(messages, tools: tools, system_prompt: context)
332
+ begin
333
+ result = with_escape_monitoring do
334
+ provider.chat_with_tools(messages, tools: tools, system_prompt: context)
335
+ end
336
+ rescue Interrupt
337
+ redirect = prompt_for_redirect
338
+ if redirect
339
+ messages << { role: :user, content: redirect }
340
+ new_messages << messages.last
341
+ next
342
+ else
343
+ raise
344
+ end
345
+ end
172
346
  total_input += result.input_tokens || 0
173
347
  total_output += result.output_tokens || 0
174
348
 
@@ -180,12 +354,14 @@ module ConsoleAgent
180
354
  end
181
355
 
182
356
  # Add assistant message with tool calls to conversation
183
- messages << provider.format_assistant_message(result)
357
+ assistant_msg = provider.format_assistant_message(result)
358
+ messages << assistant_msg
359
+ new_messages << assistant_msg
184
360
 
185
361
  # Execute each tool and show progress
186
362
  result.tool_calls.each do |tc|
187
- # ask_user handles its own display (prompt + input)
188
- if tc[:name] == 'ask_user'
363
+ # ask_user and execute_plan handle their own display
364
+ if tc[:name] == 'ask_user' || tc[:name] == 'execute_plan'
189
365
  tool_result = tools.execute(tc[:name], tc[:arguments])
190
366
  else
191
367
  args_display = format_tool_args(tc[:name], tc[:arguments])
@@ -202,7 +378,9 @@ module ConsoleAgent
202
378
  $stderr.puts "\e[35m[debug tool result] #{tool_result}\e[0m"
203
379
  end
204
380
 
205
- messages << provider.format_tool_result(tc[:id], tool_result)
381
+ tool_msg = provider.format_tool_result(tc[:id], tool_result)
382
+ messages << tool_msg
383
+ new_messages << tool_msg
206
384
  end
207
385
 
208
386
  exhausted = true if round == max_rounds - 1
@@ -217,12 +395,66 @@ module ConsoleAgent
217
395
  total_output += result.output_tokens || 0
218
396
  end
219
397
 
220
- Providers::ChatResult.new(
398
+ final_result = Providers::ChatResult.new(
221
399
  text: result ? result.text : '',
222
400
  input_tokens: total_input,
223
401
  output_tokens: total_output,
224
402
  stop_reason: result ? result.stop_reason : :end_turn
225
403
  )
404
+ [final_result, new_messages]
405
+ end
406
+
407
+ # Monitors stdin for Escape (or Ctrl+C, since raw mode disables signals)
408
+ # and raises Interrupt in the main thread when detected.
409
+ def with_escape_monitoring
410
+ require 'io/console'
411
+ return yield unless $stdin.respond_to?(:raw)
412
+
413
+ monitor = Thread.new do
414
+ Thread.current.report_on_exception = false
415
+ $stdin.raw do |io|
416
+ loop do
417
+ break if Thread.current[:stop]
418
+ ready = IO.select([io], nil, nil, 0.2)
419
+ next unless ready
420
+
421
+ char = io.read_nonblock(1) rescue nil
422
+ next unless char
423
+
424
+ if char == "\x03" # Ctrl+C (raw mode eats the signal)
425
+ Thread.main.raise(Interrupt)
426
+ break
427
+ elsif char == "\e"
428
+ # Distinguish standalone Escape from escape sequences (arrow keys, etc.)
429
+ seq = IO.select([io], nil, nil, 0.05)
430
+ if seq
431
+ io.read_nonblock(10) rescue nil # consume the sequence
432
+ else
433
+ Thread.main.raise(Interrupt)
434
+ break
435
+ end
436
+ end
437
+ end
438
+ end
439
+ rescue IOError, Errno::EIO, Errno::ENODEV, Errno::ENOTTY
440
+ # stdin is not a TTY (e.g. in tests or piped input) — silently skip
441
+ end
442
+
443
+ begin
444
+ yield
445
+ ensure
446
+ monitor[:stop] = true
447
+ monitor.join(1) rescue nil
448
+ end
449
+ end
450
+
451
+ def prompt_for_redirect
452
+ $stdout.puts "\n\e[33m Interrupted. What should the AI do differently?\e[0m"
453
+ $stdout.puts "\e[2m (Press Enter with no input to abort entirely)\e[0m"
454
+ $stdout.print "\e[33m redirect> \e[0m"
455
+ input = $stdin.gets
456
+ return nil if input.nil? || input.strip.empty?
457
+ input.strip
226
458
  end
227
459
 
228
460
  def format_tool_args(name, args)
@@ -246,6 +478,9 @@ module ConsoleAgent
246
478
  "(\"#{args['name']}\")"
247
479
  when 'recall_memories'
248
480
  args['query'] ? "(\"#{args['query']}\")" : ''
481
+ when 'execute_plan'
482
+ steps = args['steps']
483
+ steps ? "(#{steps.length} steps)" : ''
249
484
  else
250
485
  ''
251
486
  end
@@ -300,6 +535,9 @@ module ConsoleAgent
300
535
  when 'recall_memories'
301
536
  chunks = result.split("\n\n")
302
537
  chunks.length > 1 ? "#{chunks.length} memories found" : truncate(result, 80)
538
+ when 'execute_plan'
539
+ steps_done = result.scan(/^Step \d+/).length
540
+ steps_done > 0 ? "#{steps_done} steps executed" : truncate(result, 80)
303
541
  else
304
542
  truncate(result, 80)
305
543
  end
@@ -333,10 +571,104 @@ module ConsoleAgent
333
571
  $stdout.puts line
334
572
  end
335
573
 
574
+ def with_console_capture(capture_io)
575
+ old_stdout = $stdout
576
+ $stdout = TeeIO.new(old_stdout, capture_io)
577
+ yield
578
+ ensure
579
+ $stdout = old_stdout
580
+ end
581
+
582
+ def log_interactive_turn
583
+ require 'console_agent/session_logger'
584
+ session_attrs = {
585
+ conversation: @history,
586
+ input_tokens: @total_input_tokens,
587
+ output_tokens: @total_output_tokens,
588
+ code_executed: @last_interactive_code,
589
+ code_output: @last_interactive_output,
590
+ code_result: @last_interactive_result,
591
+ executed: @last_interactive_executed,
592
+ console_output: @interactive_console_capture&.string
593
+ }
594
+
595
+ if @interactive_session_id
596
+ SessionLogger.update(@interactive_session_id, session_attrs)
597
+ else
598
+ @interactive_session_id = SessionLogger.log(
599
+ session_attrs.merge(
600
+ query: @interactive_query || '(interactive session)',
601
+ mode: 'interactive',
602
+ name: @interactive_session_name
603
+ )
604
+ )
605
+ end
606
+ end
607
+
608
+ def finish_interactive_session
609
+ require 'console_agent/session_logger'
610
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @interactive_start) * 1000).round
611
+ if @interactive_session_id
612
+ SessionLogger.update(@interactive_session_id,
613
+ conversation: @history,
614
+ input_tokens: @total_input_tokens,
615
+ output_tokens: @total_output_tokens,
616
+ code_executed: @last_interactive_code,
617
+ code_output: @last_interactive_output,
618
+ code_result: @last_interactive_result,
619
+ executed: @last_interactive_executed,
620
+ console_output: @interactive_console_capture&.string,
621
+ duration_ms: duration_ms
622
+ )
623
+ elsif @interactive_query
624
+ # Session was never created (e.g., only one turn that failed to log)
625
+ log_session(
626
+ query: @interactive_query,
627
+ conversation: @history,
628
+ mode: 'interactive',
629
+ code_executed: @last_interactive_code,
630
+ code_output: @last_interactive_output,
631
+ code_result: @last_interactive_result,
632
+ executed: @last_interactive_executed,
633
+ console_output: @interactive_console_capture&.string,
634
+ start_time: @interactive_start
635
+ )
636
+ end
637
+ end
638
+
639
+ def log_session(attrs)
640
+ require 'console_agent/session_logger'
641
+ start_time = attrs.delete(:start_time)
642
+ duration_ms = if start_time
643
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
644
+ end
645
+ SessionLogger.log(
646
+ attrs.merge(
647
+ input_tokens: @total_input_tokens,
648
+ output_tokens: @total_output_tokens,
649
+ duration_ms: duration_ms
650
+ )
651
+ )
652
+ end
653
+
336
654
  def display_session_summary
337
655
  return if @total_input_tokens == 0 && @total_output_tokens == 0
338
656
 
339
657
  $stdout.puts "\e[2m[session totals — in: #{@total_input_tokens} | out: #{@total_output_tokens} | total: #{@total_input_tokens + @total_output_tokens}]\e[0m"
340
658
  end
659
+
660
+ def display_exit_info
661
+ display_session_summary
662
+ if @interactive_session_id
663
+ $stdout.puts "\e[36mSession ##{@interactive_session_id} saved.\e[0m"
664
+ if @interactive_session_name
665
+ $stdout.puts "\e[2m Resume with: ai_resume \"#{@interactive_session_name}\"\e[0m"
666
+ else
667
+ $stdout.puts "\e[2m Name it: ai_name #{@interactive_session_id}, \"descriptive_name\"\e[0m"
668
+ $stdout.puts "\e[2m Resume it: ai_resume #{@interactive_session_id}\e[0m"
669
+ end
670
+ end
671
+ $stdout.puts "\e[36mLeft ConsoleAgent interactive mode.\e[0m"
672
+ end
341
673
  end
342
674
  end
@@ -0,0 +1,79 @@
1
+ module ConsoleAgent
2
+ module SessionLogger
3
+ class << self
4
+ def log(attrs)
5
+ return unless ConsoleAgent.configuration.session_logging
6
+ return unless table_exists?
7
+
8
+ record = session_class.create!(
9
+ query: attrs[:query],
10
+ conversation: Array(attrs[:conversation]).to_json,
11
+ input_tokens: attrs[:input_tokens] || 0,
12
+ output_tokens: attrs[:output_tokens] || 0,
13
+ user_name: current_user_name,
14
+ mode: attrs[:mode].to_s,
15
+ name: attrs[:name],
16
+ code_executed: attrs[:code_executed],
17
+ code_output: attrs[:code_output],
18
+ code_result: attrs[:code_result],
19
+ console_output: attrs[:console_output],
20
+ executed: attrs[:executed] || false,
21
+ provider: ConsoleAgent.configuration.provider.to_s,
22
+ model: ConsoleAgent.configuration.resolved_model,
23
+ duration_ms: attrs[:duration_ms],
24
+ created_at: Time.respond_to?(:current) ? Time.current : Time.now
25
+ )
26
+ record.id
27
+ rescue => e
28
+ msg = "ConsoleAgent: session logging failed: #{e.class}: #{e.message}"
29
+ $stderr.puts "\e[33m#{msg}\e[0m" if $stderr.respond_to?(:puts)
30
+ ConsoleAgent.logger.warn(msg)
31
+ nil
32
+ end
33
+
34
+ def update(id, attrs)
35
+ return unless id
36
+ return unless ConsoleAgent.configuration.session_logging
37
+ return unless table_exists?
38
+
39
+ updates = {}
40
+ updates[:conversation] = Array(attrs[:conversation]).to_json if attrs.key?(:conversation)
41
+ updates[:input_tokens] = attrs[:input_tokens] if attrs.key?(:input_tokens)
42
+ updates[:output_tokens] = attrs[:output_tokens] if attrs.key?(:output_tokens)
43
+ updates[:code_executed] = attrs[:code_executed] if attrs.key?(:code_executed)
44
+ updates[:code_output] = attrs[:code_output] if attrs.key?(:code_output)
45
+ updates[:code_result] = attrs[:code_result] if attrs.key?(:code_result)
46
+ updates[:console_output] = attrs[:console_output] if attrs.key?(:console_output)
47
+ updates[:executed] = attrs[:executed] if attrs.key?(:executed)
48
+ updates[:duration_ms] = attrs[:duration_ms] if attrs.key?(:duration_ms)
49
+ updates[:name] = attrs[:name] if attrs.key?(:name)
50
+
51
+ session_class.where(id: id).update_all(updates) unless updates.empty?
52
+ rescue => e
53
+ msg = "ConsoleAgent: session update failed: #{e.class}: #{e.message}"
54
+ $stderr.puts "\e[33m#{msg}\e[0m" if $stderr.respond_to?(:puts)
55
+ ConsoleAgent.logger.warn(msg)
56
+ nil
57
+ end
58
+
59
+ private
60
+
61
+ def table_exists?
62
+ # Only cache positive results — retry on failure so transient
63
+ # errors (boot timing, connection not ready) don't stick forever
64
+ return true if @table_exists
65
+ @table_exists = session_class.connection.table_exists?('console_agent_sessions')
66
+ rescue
67
+ false
68
+ end
69
+
70
+ def session_class
71
+ Object.const_get('ConsoleAgent::Session')
72
+ end
73
+
74
+ def current_user_name
75
+ ConsoleAgent.current_user || ENV['USER']
76
+ end
77
+ end
78
+ end
79
+ end