binocs 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 +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +528 -0
- data/Rakefile +7 -0
- data/app/assets/javascripts/binocs/application.js +105 -0
- data/app/assets/stylesheets/binocs/application.css +67 -0
- data/app/channels/binocs/application_cable/channel.rb +8 -0
- data/app/channels/binocs/application_cable/connection.rb +8 -0
- data/app/channels/binocs/requests_channel.rb +13 -0
- data/app/controllers/binocs/application_controller.rb +62 -0
- data/app/controllers/binocs/requests_controller.rb +69 -0
- data/app/helpers/binocs/application_helper.rb +61 -0
- data/app/models/binocs/application_record.rb +7 -0
- data/app/models/binocs/request.rb +198 -0
- data/app/views/binocs/requests/_empty_list.html.erb +9 -0
- data/app/views/binocs/requests/_request.html.erb +61 -0
- data/app/views/binocs/requests/index.html.erb +115 -0
- data/app/views/binocs/requests/show.html.erb +227 -0
- data/app/views/layouts/binocs/application.html.erb +109 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20240101000000_create_binocs_requests.rb +36 -0
- data/exe/binocs +86 -0
- data/lib/binocs/agent.rb +153 -0
- data/lib/binocs/agent_context.rb +165 -0
- data/lib/binocs/agent_manager.rb +302 -0
- data/lib/binocs/configuration.rb +65 -0
- data/lib/binocs/engine.rb +61 -0
- data/lib/binocs/log_subscriber.rb +56 -0
- data/lib/binocs/middleware/request_recorder.rb +264 -0
- data/lib/binocs/swagger/client.rb +100 -0
- data/lib/binocs/swagger/path_matcher.rb +118 -0
- data/lib/binocs/tui/agent_output.rb +163 -0
- data/lib/binocs/tui/agents_list.rb +195 -0
- data/lib/binocs/tui/app.rb +726 -0
- data/lib/binocs/tui/colors.rb +115 -0
- data/lib/binocs/tui/filter_menu.rb +162 -0
- data/lib/binocs/tui/help_screen.rb +93 -0
- data/lib/binocs/tui/request_detail.rb +899 -0
- data/lib/binocs/tui/request_list.rb +268 -0
- data/lib/binocs/tui/spirit_animal.rb +235 -0
- data/lib/binocs/tui/window.rb +98 -0
- data/lib/binocs/tui.rb +24 -0
- data/lib/binocs/version.rb +5 -0
- data/lib/binocs.rb +27 -0
- data/lib/generators/binocs/install/install_generator.rb +61 -0
- data/lib/generators/binocs/install/templates/create_binocs_requests.rb +36 -0
- data/lib/generators/binocs/install/templates/initializer.rb +25 -0
- data/lib/tasks/binocs_tasks.rake +38 -0
- metadata +149 -0
data/lib/binocs/agent.rb
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
|
|
6
|
+
module Binocs
|
|
7
|
+
class Agent
|
|
8
|
+
STATUSES = %i[pending running completed failed stopped].freeze
|
|
9
|
+
TOOLS = %i[claude_code opencode].freeze
|
|
10
|
+
|
|
11
|
+
attr_accessor :id, :status, :tool, :worktree_path, :pid, :request_id,
|
|
12
|
+
:prompt, :created_at, :output_file, :branch_name,
|
|
13
|
+
:request_context, :exit_code
|
|
14
|
+
|
|
15
|
+
@@agents = []
|
|
16
|
+
@@mutex = Mutex.new
|
|
17
|
+
|
|
18
|
+
def initialize(attrs = {})
|
|
19
|
+
@id = attrs[:id] || SecureRandom.uuid[0, 8]
|
|
20
|
+
@status = attrs[:status] || :pending
|
|
21
|
+
@tool = attrs[:tool] || Binocs.configuration.agent_tool
|
|
22
|
+
@worktree_path = attrs[:worktree_path]
|
|
23
|
+
@pid = attrs[:pid]
|
|
24
|
+
@request_id = attrs[:request_id]
|
|
25
|
+
@prompt = attrs[:prompt]
|
|
26
|
+
@created_at = attrs[:created_at] || Time.now
|
|
27
|
+
@output_file = attrs[:output_file]
|
|
28
|
+
@branch_name = attrs[:branch_name]
|
|
29
|
+
@request_context = attrs[:request_context]
|
|
30
|
+
@exit_code = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def running?
|
|
34
|
+
@status == :running && @pid && process_alive?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def completed?
|
|
38
|
+
@status == :completed
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def failed?
|
|
42
|
+
@status == :failed
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stopped?
|
|
46
|
+
@status == :stopped
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def process_alive?
|
|
50
|
+
return false unless @pid
|
|
51
|
+
|
|
52
|
+
Process.kill(0, @pid)
|
|
53
|
+
true
|
|
54
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def stop!
|
|
59
|
+
return unless running?
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
Process.kill('TERM', @pid)
|
|
63
|
+
sleep 0.5
|
|
64
|
+
Process.kill('KILL', @pid) if process_alive?
|
|
65
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
66
|
+
# Process already dead
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@status = :stopped
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def output
|
|
73
|
+
return '' unless @output_file && File.exist?(@output_file)
|
|
74
|
+
|
|
75
|
+
File.read(@output_file)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def output_tail(lines = 50)
|
|
79
|
+
return '' unless @output_file && File.exist?(@output_file)
|
|
80
|
+
|
|
81
|
+
`tail -n #{lines} #{@output_file.shellescape}`.strip
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def duration
|
|
85
|
+
return nil unless @created_at
|
|
86
|
+
|
|
87
|
+
elapsed = Time.now - @created_at
|
|
88
|
+
if elapsed < 60
|
|
89
|
+
"#{elapsed.to_i}s"
|
|
90
|
+
elsif elapsed < 3600
|
|
91
|
+
"#{(elapsed / 60).to_i}m"
|
|
92
|
+
else
|
|
93
|
+
"#{(elapsed / 3600).to_i}h #{((elapsed % 3600) / 60).to_i}m"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def tool_command
|
|
98
|
+
case @tool
|
|
99
|
+
when :claude_code then 'claude'
|
|
100
|
+
when :opencode then 'opencode'
|
|
101
|
+
else 'claude'
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def short_prompt(max_length = 50)
|
|
106
|
+
return '' unless @prompt
|
|
107
|
+
|
|
108
|
+
@prompt.length > max_length ? "#{@prompt[0, max_length - 3]}..." : @prompt
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Class methods for agent registry
|
|
112
|
+
class << self
|
|
113
|
+
def all
|
|
114
|
+
@@mutex.synchronize { @@agents.dup }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def running
|
|
118
|
+
all.select(&:running?)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def find(id)
|
|
122
|
+
@@mutex.synchronize { @@agents.find { |a| a.id == id } }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def add(agent)
|
|
126
|
+
@@mutex.synchronize { @@agents << agent }
|
|
127
|
+
agent
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def remove(agent)
|
|
131
|
+
@@mutex.synchronize { @@agents.delete(agent) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def for_request(request_id)
|
|
135
|
+
all.select { |a| a.request_id == request_id }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def count
|
|
139
|
+
@@mutex.synchronize { @@agents.length }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def running_count
|
|
143
|
+
running.length
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def clear_completed
|
|
147
|
+
@@mutex.synchronize do
|
|
148
|
+
@@agents.reject! { |a| a.completed? || a.failed? || a.stopped? }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Binocs
|
|
6
|
+
class AgentContext
|
|
7
|
+
class << self
|
|
8
|
+
def build(request)
|
|
9
|
+
sections = []
|
|
10
|
+
|
|
11
|
+
sections << build_overview(request)
|
|
12
|
+
sections << build_params(request)
|
|
13
|
+
sections << build_headers(request)
|
|
14
|
+
sections << build_body(request)
|
|
15
|
+
sections << build_response(request)
|
|
16
|
+
sections << build_logs(request)
|
|
17
|
+
sections << build_exception(request)
|
|
18
|
+
|
|
19
|
+
sections.compact.join("\n\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def build_overview(request)
|
|
25
|
+
# Use read_attribute for 'method' to avoid conflict with Object#method
|
|
26
|
+
http_method = request.respond_to?(:read_attribute) ? request.read_attribute(:method) : request.method
|
|
27
|
+
<<~SECTION
|
|
28
|
+
## Request Overview
|
|
29
|
+
|
|
30
|
+
- **Method**: #{http_method || 'N/A'}
|
|
31
|
+
- **Path**: #{request.path || 'N/A'}
|
|
32
|
+
- **Full URL**: #{request.try(:full_url) || 'N/A'}
|
|
33
|
+
- **Controller**: #{request.controller_name || 'N/A'}
|
|
34
|
+
- **Action**: #{request.action_name || 'N/A'}
|
|
35
|
+
- **Status Code**: #{request.status_code || 'N/A'}
|
|
36
|
+
- **Duration**: #{request.try(:formatted_duration) || 'N/A'}
|
|
37
|
+
- **IP Address**: #{request.ip_address || 'N/A'}
|
|
38
|
+
- **Timestamp**: #{request.created_at&.iso8601 || 'N/A'}
|
|
39
|
+
SECTION
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build_params(request)
|
|
43
|
+
params = request.params
|
|
44
|
+
return nil if params.blank?
|
|
45
|
+
|
|
46
|
+
<<~SECTION
|
|
47
|
+
## Request Parameters
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
#{JSON.pretty_generate(params)}
|
|
51
|
+
```
|
|
52
|
+
SECTION
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_headers(request)
|
|
56
|
+
sections = []
|
|
57
|
+
|
|
58
|
+
req_headers = request.request_headers
|
|
59
|
+
if req_headers.present?
|
|
60
|
+
sections << <<~SECTION
|
|
61
|
+
## Request Headers
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
#{JSON.pretty_generate(req_headers)}
|
|
65
|
+
```
|
|
66
|
+
SECTION
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
res_headers = request.response_headers
|
|
70
|
+
if res_headers.present?
|
|
71
|
+
sections << <<~SECTION
|
|
72
|
+
## Response Headers
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
#{JSON.pretty_generate(res_headers)}
|
|
76
|
+
```
|
|
77
|
+
SECTION
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
sections.any? ? sections.join("\n\n") : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_body(request)
|
|
84
|
+
body = request.request_body
|
|
85
|
+
return nil if body.blank?
|
|
86
|
+
|
|
87
|
+
formatted_body = format_body(body)
|
|
88
|
+
|
|
89
|
+
<<~SECTION
|
|
90
|
+
## Request Body
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
#{formatted_body}
|
|
94
|
+
```
|
|
95
|
+
SECTION
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_response(request)
|
|
99
|
+
body = request.response_body
|
|
100
|
+
return nil if body.blank?
|
|
101
|
+
|
|
102
|
+
formatted_body = format_body(body)
|
|
103
|
+
|
|
104
|
+
<<~SECTION
|
|
105
|
+
## Response Body
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
#{formatted_body}
|
|
109
|
+
```
|
|
110
|
+
SECTION
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_logs(request)
|
|
114
|
+
logs = request.logs
|
|
115
|
+
return nil if logs.blank?
|
|
116
|
+
|
|
117
|
+
log_lines = logs.map do |log|
|
|
118
|
+
case log['type']
|
|
119
|
+
when 'controller'
|
|
120
|
+
"[#{log['timestamp']}] #{log['controller']}##{log['action']} - #{log['duration']}ms"
|
|
121
|
+
when 'redirect'
|
|
122
|
+
"[#{log['timestamp']}] Redirect to #{log['location']} (#{log['status']})"
|
|
123
|
+
else
|
|
124
|
+
"[#{log['timestamp']}] #{log['type']}: #{log.except('timestamp', 'type').to_json}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
<<~SECTION
|
|
129
|
+
## Request Logs
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
#{log_lines.join("\n")}
|
|
133
|
+
```
|
|
134
|
+
SECTION
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def build_exception(request)
|
|
138
|
+
exc = request.exception
|
|
139
|
+
return nil if exc.blank?
|
|
140
|
+
|
|
141
|
+
backtrace = exc['backtrace']&.first(15)&.join("\n") || 'No backtrace'
|
|
142
|
+
|
|
143
|
+
<<~SECTION
|
|
144
|
+
## Exception
|
|
145
|
+
|
|
146
|
+
- **Class**: #{exc['class']}
|
|
147
|
+
- **Message**: #{exc['message']}
|
|
148
|
+
|
|
149
|
+
### Backtrace
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
#{backtrace}
|
|
153
|
+
```
|
|
154
|
+
SECTION
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def format_body(body)
|
|
158
|
+
# Try to pretty-print JSON
|
|
159
|
+
JSON.pretty_generate(JSON.parse(body))
|
|
160
|
+
rescue JSON::ParserError
|
|
161
|
+
body
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
require 'open3'
|
|
6
|
+
|
|
7
|
+
module Binocs
|
|
8
|
+
class AgentManager
|
|
9
|
+
class << self
|
|
10
|
+
def launch(request:, prompt:, tool: nil, branch_name: nil, use_worktree: false)
|
|
11
|
+
tool ||= Binocs.configuration.agent_tool
|
|
12
|
+
|
|
13
|
+
# Create agent record
|
|
14
|
+
agent = Agent.new(
|
|
15
|
+
request_id: request.id,
|
|
16
|
+
prompt: prompt,
|
|
17
|
+
tool: tool,
|
|
18
|
+
request_context: AgentContext.build(request)
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Set up output file path early so we can log to it
|
|
22
|
+
base_dir = worktree_base_path
|
|
23
|
+
FileUtils.mkdir_p(base_dir)
|
|
24
|
+
|
|
25
|
+
# Generate a name for logs
|
|
26
|
+
timestamp = Time.now.strftime('%m%d-%H%M%S')
|
|
27
|
+
prompt_slug = generate_slug(agent.prompt)
|
|
28
|
+
log_name = "#{timestamp}-#{prompt_slug}"
|
|
29
|
+
agent.output_file = File.join(base_dir, "#{log_name}.log")
|
|
30
|
+
|
|
31
|
+
# Start logging
|
|
32
|
+
log_to_agent(agent, "=" * 60)
|
|
33
|
+
log_to_agent(agent, "Binocs Agent Started")
|
|
34
|
+
log_to_agent(agent, "Time: #{Time.now}")
|
|
35
|
+
log_to_agent(agent, "Tool: #{agent.tool}")
|
|
36
|
+
log_to_agent(agent, "Mode: #{use_worktree ? 'New Worktree' : 'Current Branch'}")
|
|
37
|
+
log_to_agent(agent, "=" * 60)
|
|
38
|
+
log_to_agent(agent, "")
|
|
39
|
+
|
|
40
|
+
if use_worktree
|
|
41
|
+
# Create worktree for isolated work
|
|
42
|
+
worktree_name = branch_name && !branch_name.empty? ? branch_name : log_name
|
|
43
|
+
log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Creating git worktree...")
|
|
44
|
+
worktree_path, branch_name = create_worktree(agent, worktree_name)
|
|
45
|
+
agent.worktree_path = worktree_path
|
|
46
|
+
agent.branch_name = branch_name
|
|
47
|
+
log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Worktree created: #{worktree_path}")
|
|
48
|
+
log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Branch: #{branch_name}")
|
|
49
|
+
log_to_agent(agent, "")
|
|
50
|
+
|
|
51
|
+
# Write context file for the agent
|
|
52
|
+
context_file = File.join(worktree_path, '.binocs-context.md')
|
|
53
|
+
File.write(context_file, agent.request_context)
|
|
54
|
+
log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Context file written: .binocs-context.md")
|
|
55
|
+
else
|
|
56
|
+
# Run in current directory on current branch
|
|
57
|
+
agent.worktree_path = find_git_root
|
|
58
|
+
agent.branch_name = current_branch_name
|
|
59
|
+
log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Running on current branch: #{agent.branch_name}")
|
|
60
|
+
log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Directory: #{agent.worktree_path}")
|
|
61
|
+
log_to_agent(agent, "")
|
|
62
|
+
|
|
63
|
+
# Write context file in current directory
|
|
64
|
+
context_file = File.join(agent.worktree_path, '.binocs-context.md')
|
|
65
|
+
File.write(context_file, agent.request_context)
|
|
66
|
+
log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Context file written: .binocs-context.md")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Register agent
|
|
70
|
+
Agent.add(agent)
|
|
71
|
+
|
|
72
|
+
# Spawn the process
|
|
73
|
+
log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Starting #{agent.tool_command}...")
|
|
74
|
+
log_to_agent(agent, "")
|
|
75
|
+
log_to_agent(agent, "-" * 60)
|
|
76
|
+
log_to_agent(agent, "Agent Output:")
|
|
77
|
+
log_to_agent(agent, "-" * 60)
|
|
78
|
+
log_to_agent(agent, "")
|
|
79
|
+
spawn_agent(agent)
|
|
80
|
+
|
|
81
|
+
agent
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def continue_session(agent:, prompt:, tool: nil)
|
|
85
|
+
tool ||= agent.tool
|
|
86
|
+
|
|
87
|
+
# Update agent for new session
|
|
88
|
+
agent.prompt = prompt
|
|
89
|
+
agent.tool = tool
|
|
90
|
+
agent.status = :pending
|
|
91
|
+
agent.created_at = Time.now
|
|
92
|
+
|
|
93
|
+
# Log continuation
|
|
94
|
+
log_to_agent(agent, "")
|
|
95
|
+
log_to_agent(agent, "=" * 60)
|
|
96
|
+
log_to_agent(agent, "Continuing Session")
|
|
97
|
+
log_to_agent(agent, "Time: #{Time.now}")
|
|
98
|
+
log_to_agent(agent, "Tool: #{tool}")
|
|
99
|
+
log_to_agent(agent, "=" * 60)
|
|
100
|
+
log_to_agent(agent, "")
|
|
101
|
+
|
|
102
|
+
log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Continuing in: #{agent.worktree_path}")
|
|
103
|
+
log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] New prompt: #{prompt[0, 50]}...")
|
|
104
|
+
log_to_agent(agent, "")
|
|
105
|
+
|
|
106
|
+
# Spawn the process in the same directory
|
|
107
|
+
log_to_agent(agent, "[#{Time.now.strftime('%H:%M:%S')}] Starting #{agent.tool_command}...")
|
|
108
|
+
log_to_agent(agent, "")
|
|
109
|
+
log_to_agent(agent, "-" * 60)
|
|
110
|
+
log_to_agent(agent, "Agent Output:")
|
|
111
|
+
log_to_agent(agent, "-" * 60)
|
|
112
|
+
log_to_agent(agent, "")
|
|
113
|
+
spawn_agent(agent)
|
|
114
|
+
|
|
115
|
+
agent
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def log_to_agent(agent, message)
|
|
119
|
+
return unless agent.output_file
|
|
120
|
+
File.open(agent.output_file, 'a') { |f| f.puts(message) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def create_worktree(agent, worktree_name)
|
|
124
|
+
base_dir = worktree_base_path
|
|
125
|
+
worktree_path = File.join(base_dir, worktree_name)
|
|
126
|
+
branch_name = "agent/#{worktree_name}"
|
|
127
|
+
|
|
128
|
+
# Get current repo root
|
|
129
|
+
repo_root = find_git_root
|
|
130
|
+
|
|
131
|
+
# Create the worktree with a new branch, capturing output
|
|
132
|
+
Dir.chdir(repo_root) do
|
|
133
|
+
output = `git worktree add -b #{branch_name.shellescape} #{worktree_path.shellescape} HEAD 2>&1`
|
|
134
|
+
unless $?.success?
|
|
135
|
+
log_to_agent(agent, "[ERROR] Git worktree creation failed:")
|
|
136
|
+
log_to_agent(agent, output)
|
|
137
|
+
raise "Failed to create git worktree at #{worktree_path}: #{output}"
|
|
138
|
+
end
|
|
139
|
+
log_to_agent(agent, output) unless output.strip.empty?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
[worktree_path, branch_name]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def generate_slug(prompt)
|
|
146
|
+
return 'task' if prompt.nil? || prompt.empty?
|
|
147
|
+
|
|
148
|
+
# Take first few words, remove special chars, join with dashes
|
|
149
|
+
words = prompt.downcase.gsub(/[^a-z0-9\s]/, '').split.first(4)
|
|
150
|
+
slug = words.join('-')
|
|
151
|
+
slug = slug[0, 25] if slug.length > 25
|
|
152
|
+
slug.empty? ? 'task' : slug
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def spawn_agent(agent)
|
|
156
|
+
return unless agent.worktree_path && Dir.exist?(agent.worktree_path)
|
|
157
|
+
|
|
158
|
+
# Build the full prompt including context
|
|
159
|
+
full_prompt = build_full_prompt(agent)
|
|
160
|
+
|
|
161
|
+
# Create a prompt file for the agent to read
|
|
162
|
+
prompt_file = File.join(agent.worktree_path, '.binocs-prompt.md')
|
|
163
|
+
File.write(prompt_file, full_prompt)
|
|
164
|
+
|
|
165
|
+
# Build command arguments based on tool
|
|
166
|
+
cmd_args = build_agent_command_args(agent, prompt_file)
|
|
167
|
+
|
|
168
|
+
# Spawn the process
|
|
169
|
+
pid = Process.spawn(
|
|
170
|
+
*cmd_args,
|
|
171
|
+
chdir: agent.worktree_path,
|
|
172
|
+
out: [agent.output_file, 'a'],
|
|
173
|
+
err: [agent.output_file, 'a'],
|
|
174
|
+
pgroup: true
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
Process.detach(pid)
|
|
178
|
+
|
|
179
|
+
agent.pid = pid
|
|
180
|
+
agent.status = :running
|
|
181
|
+
|
|
182
|
+
# Start a monitor thread
|
|
183
|
+
start_monitor_thread(agent)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def cleanup(agent)
|
|
187
|
+
# Stop the process if running
|
|
188
|
+
agent.stop! if agent.running?
|
|
189
|
+
|
|
190
|
+
# Remove the worktree
|
|
191
|
+
if agent.worktree_path && Dir.exist?(agent.worktree_path)
|
|
192
|
+
repo_root = find_git_root
|
|
193
|
+
|
|
194
|
+
Dir.chdir(repo_root) do
|
|
195
|
+
system("git worktree remove #{agent.worktree_path.shellescape} --force",
|
|
196
|
+
out: File::NULL, err: File::NULL)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Also delete the branch if it exists
|
|
200
|
+
if agent.branch_name
|
|
201
|
+
Dir.chdir(repo_root) do
|
|
202
|
+
system("git branch -D #{agent.branch_name.shellescape}",
|
|
203
|
+
out: File::NULL, err: File::NULL)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Remove from registry
|
|
209
|
+
Agent.remove(agent)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def open_worktree(agent)
|
|
213
|
+
return unless agent.worktree_path && Dir.exist?(agent.worktree_path)
|
|
214
|
+
|
|
215
|
+
# Open in file manager
|
|
216
|
+
if RbConfig::CONFIG['host_os'] =~ /darwin/
|
|
217
|
+
system("open", agent.worktree_path)
|
|
218
|
+
elsif RbConfig::CONFIG['host_os'] =~ /linux/
|
|
219
|
+
system("xdg-open", agent.worktree_path)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
def worktree_base_path
|
|
226
|
+
base = Binocs.configuration.agent_worktree_base
|
|
227
|
+
if base.start_with?('/')
|
|
228
|
+
base
|
|
229
|
+
else
|
|
230
|
+
File.expand_path(base, find_git_root)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def find_git_root
|
|
235
|
+
dir = Dir.pwd
|
|
236
|
+
while dir != '/'
|
|
237
|
+
return dir if File.exist?(File.join(dir, '.git'))
|
|
238
|
+
|
|
239
|
+
dir = File.dirname(dir)
|
|
240
|
+
end
|
|
241
|
+
Dir.pwd
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def current_branch_name
|
|
245
|
+
Dir.chdir(find_git_root) do
|
|
246
|
+
`git rev-parse --abbrev-ref HEAD`.strip
|
|
247
|
+
end
|
|
248
|
+
rescue
|
|
249
|
+
'unknown'
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def build_full_prompt(agent)
|
|
253
|
+
<<~PROMPT
|
|
254
|
+
# Request Context
|
|
255
|
+
|
|
256
|
+
The following is context from an HTTP request that was captured by Binocs.
|
|
257
|
+
Use this information to understand the issue and implement a fix.
|
|
258
|
+
|
|
259
|
+
#{agent.request_context}
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
# Task
|
|
264
|
+
|
|
265
|
+
#{agent.prompt}
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
Note: The request context is also saved in `.binocs-context.md` for reference.
|
|
270
|
+
PROMPT
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def build_agent_command_args(agent, prompt_file)
|
|
274
|
+
prompt_content = File.read(prompt_file)
|
|
275
|
+
|
|
276
|
+
case agent.tool
|
|
277
|
+
when :claude_code
|
|
278
|
+
# Claude Code: use -p for prompt, --dangerously-skip-permissions for autonomous mode
|
|
279
|
+
[agent.tool_command, '-p', prompt_content, '--dangerously-skip-permissions']
|
|
280
|
+
when :opencode
|
|
281
|
+
# OpenCode - run with prompt flag
|
|
282
|
+
[agent.tool_command, '-p', prompt_content]
|
|
283
|
+
else
|
|
284
|
+
[agent.tool_command, '-p', prompt_content]
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def start_monitor_thread(agent)
|
|
289
|
+
Thread.new do
|
|
290
|
+
begin
|
|
291
|
+
Process.wait(agent.pid)
|
|
292
|
+
agent.exit_code = $?.exitstatus
|
|
293
|
+
agent.status = agent.exit_code == 0 ? :completed : :failed
|
|
294
|
+
rescue Errno::ECHILD
|
|
295
|
+
# Process already reaped
|
|
296
|
+
agent.status = :completed unless agent.status == :stopped
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Binocs
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :enabled,
|
|
6
|
+
:retention_period,
|
|
7
|
+
:max_body_size,
|
|
8
|
+
:ignored_paths,
|
|
9
|
+
:ignored_content_types,
|
|
10
|
+
:basic_auth_username,
|
|
11
|
+
:basic_auth_password,
|
|
12
|
+
:max_requests,
|
|
13
|
+
:record_request_body,
|
|
14
|
+
:record_response_body,
|
|
15
|
+
:swagger_spec_url,
|
|
16
|
+
:swagger_ui_url,
|
|
17
|
+
:agent_tool,
|
|
18
|
+
:agent_worktree_base,
|
|
19
|
+
:login_path,
|
|
20
|
+
:authentication_method
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@enabled = true
|
|
24
|
+
@retention_period = 24.hours
|
|
25
|
+
@max_body_size = 64.kilobytes
|
|
26
|
+
@ignored_paths = %w[/assets /packs /binocs /cable]
|
|
27
|
+
@ignored_content_types = %w[image/ video/ audio/ font/]
|
|
28
|
+
@basic_auth_username = nil
|
|
29
|
+
@basic_auth_password = nil
|
|
30
|
+
@max_requests = 1000
|
|
31
|
+
@record_request_body = true
|
|
32
|
+
@record_response_body = true
|
|
33
|
+
@swagger_spec_url = '/api-docs'
|
|
34
|
+
@swagger_ui_url = '/api-docs/index.html'
|
|
35
|
+
@agent_tool = :claude_code
|
|
36
|
+
@agent_worktree_base = '../binocs-agents'
|
|
37
|
+
@login_path = nil # Auto-detected from Devise if available, or set manually
|
|
38
|
+
@authentication_method = nil # e.g., :authenticate_user! or a Proc
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def basic_auth_enabled?
|
|
42
|
+
basic_auth_username.present? && basic_auth_password.present?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def swagger_enabled?
|
|
46
|
+
swagger_spec_url.present?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def resolved_login_path
|
|
50
|
+
return @login_path if @login_path.present?
|
|
51
|
+
|
|
52
|
+
# Try to auto-detect Devise login path
|
|
53
|
+
if defined?(Devise) && defined?(Rails.application.routes)
|
|
54
|
+
begin
|
|
55
|
+
Rails.application.routes.url_helpers.new_user_session_path
|
|
56
|
+
rescue NoMethodError
|
|
57
|
+
# Devise might use a different scope
|
|
58
|
+
'/users/sign_in'
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
'/login'
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|