agentboard 0.2.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/CHANGELOG.md +62 -0
- data/MIT-LICENSE +20 -0
- data/README.md +316 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/agentboard/application.css +510 -0
- data/app/controllers/agentboard/agents_controller.rb +10 -0
- data/app/controllers/agentboard/analytics_controller.rb +37 -0
- data/app/controllers/agentboard/application_controller.rb +90 -0
- data/app/controllers/agentboard/approvals_controller.rb +15 -0
- data/app/controllers/agentboard/assets_controller.rb +32 -0
- data/app/controllers/agentboard/board_controller.rb +46 -0
- data/app/controllers/agentboard/captures_controller.rb +17 -0
- data/app/controllers/agentboard/comments_controller.rb +9 -0
- data/app/controllers/agentboard/criteria_controller.rb +17 -0
- data/app/controllers/agentboard/dependencies_controller.rb +20 -0
- data/app/controllers/agentboard/executions_controller.rb +48 -0
- data/app/controllers/agentboard/milestones_controller.rb +23 -0
- data/app/controllers/agentboard/pulse_controller.rb +12 -0
- data/app/controllers/agentboard/sessions_controller.rb +19 -0
- data/app/controllers/agentboard/settings_controller.rb +9 -0
- data/app/controllers/agentboard/tickets_controller.rb +139 -0
- data/app/controllers/agentboard/workflow_runs_controller.rb +16 -0
- data/app/controllers/agentboard/workflows_controller.rb +62 -0
- data/app/helpers/agentboard/application_helper.rb +196 -0
- data/app/javascript/agentboard/application.js +227 -0
- data/app/javascript/agentboard/capture.js +299 -0
- data/app/jobs/agentboard/application_job.rb +4 -0
- data/app/models/agentboard/acceptance_criterion.rb +23 -0
- data/app/models/agentboard/agent_profile.rb +16 -0
- data/app/models/agentboard/agent_session.rb +30 -0
- data/app/models/agentboard/application_record.rb +13 -0
- data/app/models/agentboard/approval.rb +29 -0
- data/app/models/agentboard/artifact.rb +11 -0
- data/app/models/agentboard/comment.rb +9 -0
- data/app/models/agentboard/event.rb +37 -0
- data/app/models/agentboard/execution.rb +51 -0
- data/app/models/agentboard/idempotency_key.rb +5 -0
- data/app/models/agentboard/milestone.rb +7 -0
- data/app/models/agentboard/outbox_message.rb +11 -0
- data/app/models/agentboard/project.rb +43 -0
- data/app/models/agentboard/repository_snapshot.rb +5 -0
- data/app/models/agentboard/session_ticket_link.rb +18 -0
- data/app/models/agentboard/ticket.rb +81 -0
- data/app/models/agentboard/ticket_dependency.rb +34 -0
- data/app/models/agentboard/ticket_lease.rb +11 -0
- data/app/models/agentboard/usage_record.rb +10 -0
- data/app/models/agentboard/verification_result.rb +12 -0
- data/app/models/agentboard/webhook_delivery.rb +7 -0
- data/app/models/agentboard/workflow_definition.rb +20 -0
- data/app/models/agentboard/workflow_run.rb +21 -0
- data/app/models/agentboard/workflow_step.rb +12 -0
- data/app/services/agentboard/approvals.rb +76 -0
- data/app/services/agentboard/captures.rb +113 -0
- data/app/services/agentboard/completion_policy.rb +117 -0
- data/app/services/agentboard/current.rb +5 -0
- data/app/services/agentboard/data_governance.rb +68 -0
- data/app/services/agentboard/doctor.rb +133 -0
- data/app/services/agentboard/event_store.rb +88 -0
- data/app/services/agentboard/evidence.rb +52 -0
- data/app/services/agentboard/executions.rb +198 -0
- data/app/services/agentboard/hook_ingestion.rb +100 -0
- data/app/services/agentboard/idempotency.rb +28 -0
- data/app/services/agentboard/lease_service.rb +107 -0
- data/app/services/agentboard/mcp_server.rb +138 -0
- data/app/services/agentboard/mcp_tools.rb +341 -0
- data/app/services/agentboard/outbox.rb +44 -0
- data/app/services/agentboard/pull_requests.rb +65 -0
- data/app/services/agentboard/runtime/execution_runner.rb +352 -0
- data/app/services/agentboard/runtime/ipc_handler.rb +55 -0
- data/app/services/agentboard/runtime/reconciler.rb +42 -0
- data/app/services/agentboard/runtime/supervisor.rb +96 -0
- data/app/services/agentboard/session_association.rb +44 -0
- data/app/services/agentboard/session_correlation.rb +56 -0
- data/app/services/agentboard/session_credential.rb +44 -0
- data/app/services/agentboard/ticket_advisor.rb +60 -0
- data/app/services/agentboard/tickets.rb +218 -0
- data/app/services/agentboard/verification_service.rb +60 -0
- data/app/services/agentboard/workflow_engine.rb +261 -0
- data/app/views/agentboard/agents/index.html.erb +51 -0
- data/app/views/agentboard/analytics/show.html.erb +78 -0
- data/app/views/agentboard/approvals/_approval.html.erb +27 -0
- data/app/views/agentboard/approvals/index.html.erb +31 -0
- data/app/views/agentboard/board/_card.html.erb +36 -0
- data/app/views/agentboard/board/show.html.erb +73 -0
- data/app/views/agentboard/executions/index.html.erb +33 -0
- data/app/views/agentboard/executions/show.html.erb +126 -0
- data/app/views/agentboard/milestones/index.html.erb +28 -0
- data/app/views/agentboard/settings/show.html.erb +33 -0
- data/app/views/agentboard/shared/_dag.html.erb +24 -0
- data/app/views/agentboard/tickets/_form.html.erb +38 -0
- data/app/views/agentboard/tickets/edit.html.erb +5 -0
- data/app/views/agentboard/tickets/index.html.erb +51 -0
- data/app/views/agentboard/tickets/new.html.erb +6 -0
- data/app/views/agentboard/tickets/show.html.erb +343 -0
- data/app/views/agentboard/tickets/suggestions.html.erb +21 -0
- data/app/views/agentboard/workflow_runs/show.html.erb +28 -0
- data/app/views/agentboard/workflows/index.html.erb +49 -0
- data/app/views/agentboard/workflows/show.html.erb +15 -0
- data/app/views/layouts/agentboard/application.html.erb +93 -0
- data/config/routes.rb +49 -0
- data/db/migrate/20261114000001_create_agentboard_core.rb +144 -0
- data/db/migrate/20261114000002_create_agentboard_runtime.rb +213 -0
- data/db/migrate/20261115000001_create_agentboard_webhook_deliveries.rb +18 -0
- data/exe/agentboard +7 -0
- data/lib/agentboard/agent_container.rb +124 -0
- data/lib/agentboard/authorization.rb +58 -0
- data/lib/agentboard/capture/middleware.rb +54 -0
- data/lib/agentboard/capture.rb +57 -0
- data/lib/agentboard/cli.rb +818 -0
- data/lib/agentboard/command_policy.rb +44 -0
- data/lib/agentboard/configuration.rb +160 -0
- data/lib/agentboard/container_sandbox.rb +65 -0
- data/lib/agentboard/context_builder.rb +243 -0
- data/lib/agentboard/egress_proxy.rb +95 -0
- data/lib/agentboard/engine.rb +27 -0
- data/lib/agentboard/errors.rb +42 -0
- data/lib/agentboard/event_schema.rb +37 -0
- data/lib/agentboard/extensions.rb +58 -0
- data/lib/agentboard/git/repository.rb +92 -0
- data/lib/agentboard/git/worktree_manager.rb +107 -0
- data/lib/agentboard/git.rb +4 -0
- data/lib/agentboard/ids.rb +33 -0
- data/lib/agentboard/json_log_formatter.rb +28 -0
- data/lib/agentboard/path_guard.rb +27 -0
- data/lib/agentboard/process_ancestry.rb +40 -0
- data/lib/agentboard/providers/base.rb +107 -0
- data/lib/agentboard/providers/claude_code.rb +219 -0
- data/lib/agentboard/providers/codex.rb +177 -0
- data/lib/agentboard/providers/compatibility/claude.yml +13 -0
- data/lib/agentboard/providers/compatibility/codex.yml +10 -0
- data/lib/agentboard/providers/config_editor.rb +106 -0
- data/lib/agentboard/providers/cursor.rb +40 -0
- data/lib/agentboard/providers/fake.rb +59 -0
- data/lib/agentboard/providers/fake_agent_script.rb +47 -0
- data/lib/agentboard/providers/generic_cli.rb +35 -0
- data/lib/agentboard/providers/registry.rb +31 -0
- data/lib/agentboard/providers/types.rb +32 -0
- data/lib/agentboard/providers.rb +26 -0
- data/lib/agentboard/rails_introspection.rb +49 -0
- data/lib/agentboard/redactor.rb +62 -0
- data/lib/agentboard/result.rb +46 -0
- data/lib/agentboard/runtime/ipc.rb +193 -0
- data/lib/agentboard/runtime.rb +1 -0
- data/lib/agentboard/state_machines.rb +71 -0
- data/lib/agentboard/subprocess.rb +168 -0
- data/lib/agentboard/templates/Dockerfile.agent +21 -0
- data/lib/agentboard/verification.rb +79 -0
- data/lib/agentboard/version.rb +3 -0
- data/lib/agentboard/workflows/condition.rb +166 -0
- data/lib/agentboard/workflows/definition.rb +208 -0
- data/lib/agentboard/workflows.rb +2 -0
- data/lib/agentboard.rb +73 -0
- data/lib/generators/agentboard/install/install_generator.rb +67 -0
- data/lib/generators/agentboard/install/templates/config.yml +81 -0
- data/lib/generators/agentboard/install/templates/feature_delivery.yml +25 -0
- data/lib/generators/agentboard/install/templates/initializer.rb +23 -0
- data/lib/tasks/agentboard_tasks.rake +6 -0
- metadata +285 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
module Providers
|
|
3
|
+
# Claude Code adapter.
|
|
4
|
+
#
|
|
5
|
+
# Passive: registers the AgentBoard MCP server in the project's `.mcp.json` and installs
|
|
6
|
+
# lifecycle hooks in `.claude/settings.local.json` (git-ignored by Claude Code), each
|
|
7
|
+
# running `agentboard hook ingest --provider claude --event <Event>`.
|
|
8
|
+
#
|
|
9
|
+
# Active: `claude -p --output-format stream-json --verbose` in the worktree with the
|
|
10
|
+
# prompt on stdin. Never enables `--dangerously-skip-permissions`; permission mode
|
|
11
|
+
# defaults to `acceptEdits` (file edits allowed, shell tools denied in print mode).
|
|
12
|
+
#
|
|
13
|
+
# Flags were checked against `claude --help` for the versions listed in
|
|
14
|
+
# compatibility/claude.yml. Hook payload fields follow Claude Code's documented hook input.
|
|
15
|
+
class ClaudeCode < Base
|
|
16
|
+
HOOK_EVENTS = %w[SessionStart UserPromptSubmit PreToolUse PostToolUse Notification Stop SubagentStop SessionEnd].freeze
|
|
17
|
+
HOOK_EVENT_MAP = {
|
|
18
|
+
"SessionStart" => "agent.session.started",
|
|
19
|
+
"UserPromptSubmit" => "agent.session.prompt_submitted",
|
|
20
|
+
"PreToolUse" => "agent.tool.started",
|
|
21
|
+
"PostToolUse" => "agent.tool.completed",
|
|
22
|
+
"Notification" => "agent.session.notification",
|
|
23
|
+
"Stop" => "agent.session.turn_completed",
|
|
24
|
+
"SubagentStop" => "agent.session.subagent_completed",
|
|
25
|
+
"SessionEnd" => "agent.session.ended"
|
|
26
|
+
}.freeze
|
|
27
|
+
# Only documented, low-sensitivity fields are retained from hook input.
|
|
28
|
+
HOOK_FIELDS = %w[session_id hook_event_name cwd tool_name source reason message stop_hook_active permission_mode].freeze
|
|
29
|
+
PERMISSION_MODES = %w[default acceptEdits plan].freeze
|
|
30
|
+
|
|
31
|
+
def self.key = :claude
|
|
32
|
+
def self.display_name = "Claude Code"
|
|
33
|
+
def self.binary = "claude"
|
|
34
|
+
|
|
35
|
+
def probe(environment: ENV)
|
|
36
|
+
path = which("claude")
|
|
37
|
+
return ProbeResult.new(provider: key, installed: false, supported_capabilities: [], validation: "not installed",
|
|
38
|
+
diagnostics: [ "`claude` binary not found on PATH" ]) unless path
|
|
39
|
+
|
|
40
|
+
raw = run_version([ path, "--version" ])
|
|
41
|
+
version = raw.to_s[/\d+\.\d+\.\d+/]
|
|
42
|
+
all = %i[mcp passive_hooks active_execution structured_events streaming cancellation session_resume permission_requests]
|
|
43
|
+
caps, validation, diags = negotiate(version, all, safe_caps: %i[mcp passive_hooks])
|
|
44
|
+
ProbeResult.new(provider: key, installed: true, version: version, authenticated: nil,
|
|
45
|
+
supported_capabilities: caps, validation: validation,
|
|
46
|
+
diagnostics: diags + [ "Authentication is managed by Claude Code and is not inspected by AgentBoard." ])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# -- passive ---------------------------------------------------------------------
|
|
50
|
+
def mcp_entry(root:)
|
|
51
|
+
{ "command" => "bundle", "args" => [ "exec", "agentboard", "mcp", "serve", "--root", root.to_s ], "env" => {} }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def hook_command(event)
|
|
55
|
+
%(cd "$CLAUDE_PROJECT_DIR" && bundle exec agentboard hook ingest --provider claude --event #{event})
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def install_passive_hooks(project:, root:)
|
|
59
|
+
mcp_path = File.join(root, ".mcp.json")
|
|
60
|
+
settings_path = File.join(root, ".claude", "settings.local.json")
|
|
61
|
+
backups = [ ConfigEditor.backup!(mcp_path), ConfigEditor.backup!(settings_path) ].compact
|
|
62
|
+
changes = []
|
|
63
|
+
|
|
64
|
+
changes << ConfigEditor.merge_json!(mcp_path, %w[mcpServers agentboard], mcp_entry(root: root),
|
|
65
|
+
owned: ->(e) { e.is_a?(Hash) && Array(e["args"]).include?("agentboard") })
|
|
66
|
+
|
|
67
|
+
settings = ConfigEditor.read_json(settings_path)
|
|
68
|
+
settings["hooks"] ||= {}
|
|
69
|
+
added = []
|
|
70
|
+
HOOK_EVENTS.each do |event|
|
|
71
|
+
groups = (settings["hooks"][event] ||= [])
|
|
72
|
+
command = hook_command(event)
|
|
73
|
+
next if groups.any? { |g| Array(g["hooks"]).any? { |h| h["command"].to_s.include?("agentboard hook ingest") } }
|
|
74
|
+
|
|
75
|
+
groups << { "matcher" => "", "hooks" => [ { "type" => "command", "command" => command, "timeout" => 10 } ] }
|
|
76
|
+
added << event
|
|
77
|
+
end
|
|
78
|
+
ConfigEditor.write_json(settings_path, settings) if added.any?
|
|
79
|
+
changes << { path: settings_path, keys: [ "hooks" ], action: added.any? ? "added" : "unchanged", events: added }
|
|
80
|
+
|
|
81
|
+
manifest = { "provider" => key, "mcp" => { "path" => mcp_path, "keys" => %w[mcpServers agentboard] },
|
|
82
|
+
"hooks" => { "path" => settings_path, "events" => HOOK_EVENTS } }
|
|
83
|
+
ConfigEditor.write_manifest!(root, key, manifest)
|
|
84
|
+
SetupResult.new(provider: key, changes: changes, manifest: manifest, backups: backups, warnings: [])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def remove_passive_hooks(project:, root:, manifest: nil)
|
|
88
|
+
manifest ||= ConfigEditor.read_manifest(root, key) || {}
|
|
89
|
+
removed = []
|
|
90
|
+
if (m = manifest["mcp"]) && ConfigEditor.delete_json!(m["path"], m["keys"])
|
|
91
|
+
removed << m["path"]
|
|
92
|
+
end
|
|
93
|
+
if (h = manifest["hooks"]) && File.exist?(h["path"])
|
|
94
|
+
settings = ConfigEditor.read_json(h["path"])
|
|
95
|
+
(settings["hooks"] || {}).each_value do |groups|
|
|
96
|
+
groups.each { |g| g["hooks"] = Array(g["hooks"]).reject { |x| x["command"].to_s.include?("agentboard hook ingest") } }
|
|
97
|
+
groups.reject! { |g| g["hooks"].empty? }
|
|
98
|
+
end
|
|
99
|
+
settings["hooks"]&.reject! { |_k, v| v.empty? }
|
|
100
|
+
settings.delete("hooks") if settings["hooks"] && settings["hooks"].empty?
|
|
101
|
+
ConfigEditor.write_json(h["path"], settings)
|
|
102
|
+
removed << h["path"]
|
|
103
|
+
end
|
|
104
|
+
removed
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse_hook(event_name:, payload:)
|
|
108
|
+
data = payload.is_a?(Hash) ? payload : {}
|
|
109
|
+
name = data["hook_event_name"].presence || event_name.to_s
|
|
110
|
+
type = HOOK_EVENT_MAP.fetch(name, "agent.provider.unknown")
|
|
111
|
+
kept = data.slice(*HOOK_FIELDS)
|
|
112
|
+
if (input = data["tool_input"]).is_a?(Hash)
|
|
113
|
+
# Keep a target path for traceability; never retain full tool input (may hold secrets).
|
|
114
|
+
kept["target"] = (input["file_path"] || input["path"] || input["notebook_path"]).to_s[0, 500].presence
|
|
115
|
+
kept["command_excerpt"] = input["command"].to_s[0, 120].presence if data["tool_name"] == "Bash"
|
|
116
|
+
end
|
|
117
|
+
[ NormalizedEvent.build(type, kept.compact, external_session_id: data["session_id"]) ]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# -- active ----------------------------------------------------------------------
|
|
121
|
+
def launch_spec(execution:, worktree:, context_path:, prompt:)
|
|
122
|
+
config = execution.agent_profile&.config || {}
|
|
123
|
+
container = execution.metadata["isolation"] == "container"
|
|
124
|
+
# Inside the container the container is the security boundary (no host access, egress
|
|
125
|
+
# allowlist), so the agent may use tools without prompts. On the host it never can.
|
|
126
|
+
mode = config.fetch("permission_mode", container ? "bypassPermissions" : "acceptEdits").to_s
|
|
127
|
+
allowed_modes = container ? PERMISSION_MODES + %w[bypassPermissions] : PERMISSION_MODES
|
|
128
|
+
raise Error.policy_denied("Permission mode #{mode} is not allowed#{" outside a container" unless container}") unless allowed_modes.include?(mode)
|
|
129
|
+
|
|
130
|
+
argv = [ "claude", "-p", "--output-format", "stream-json", "--verbose", "--permission-mode", mode ]
|
|
131
|
+
argv += [ "--model", config["model"].to_s ] if config["model"].to_s.match?(/\A[\w.\-:]+\z/)
|
|
132
|
+
allowed = Array(config["allowed_tools"]).map(&:to_s).grep(/\A[\w()*:,. \-\/]+\z/)
|
|
133
|
+
argv += [ "--allowedTools", *allowed ] if allowed.any?
|
|
134
|
+
env = {}
|
|
135
|
+
if config.fetch("interactive_permissions", true) && !container
|
|
136
|
+
# Permission prompts are answered by a human through AgentBoard approvals: Claude calls
|
|
137
|
+
# the execution-bound AgentBoard MCP server, which blocks until a decision is made.
|
|
138
|
+
argv += [ "--mcp-config", JSON.generate("mcpServers" => { "agentboard" => permission_bridge(execution) }),
|
|
139
|
+
"--strict-mcp-config", "--permission-prompt-tool", "mcp__agentboard__permission_prompt" ]
|
|
140
|
+
env["MCP_TOOL_TIMEOUT"] = ((Agentboard.config.runtime.fetch("permission_timeout_seconds", 600).to_i + 30) * 1000).to_s
|
|
141
|
+
end
|
|
142
|
+
LaunchSpec.new(argv: argv, env: env, stdin_data: prompt, chdir: worktree,
|
|
143
|
+
passthrough_env: %w[ANTHROPIC_API_KEY CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX CLAUDE_CONFIG_DIR])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def permission_bridge(execution)
|
|
147
|
+
root = Agentboard.config.root_path.to_s
|
|
148
|
+
{ "command" => RbConfig.ruby,
|
|
149
|
+
"args" => [ File.expand_path("../../../exe/agentboard", __dir__), "mcp", "serve", "--root", root, "--execution", execution.id, "--provider", "claude" ],
|
|
150
|
+
"env" => { "BUNDLE_GEMFILE" => File.join(root, "Gemfile"), "RUBYOPT" => "-rbundler/setup", "RAILS_ENV" => ENV.fetch("RAILS_ENV", "development") } }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def resume_spec(execution:, worktree:, input:)
|
|
154
|
+
sid = execution.metadata["provider_session_id"].to_s
|
|
155
|
+
raise UnsupportedCapability.new(:session_resume, provider: key) unless sid.match?(/\A[\w-]{8,}\z/) && supports?(:session_resume)
|
|
156
|
+
|
|
157
|
+
argv = [ "claude", "-p", "--output-format", "stream-json", "--verbose", "--resume", sid, "--permission-mode", "acceptEdits" ]
|
|
158
|
+
env = {}
|
|
159
|
+
if (execution.agent_profile&.config || {}).fetch("interactive_permissions", true)
|
|
160
|
+
argv += [ "--mcp-config", JSON.generate("mcpServers" => { "agentboard" => permission_bridge(execution) }),
|
|
161
|
+
"--strict-mcp-config", "--permission-prompt-tool", "mcp__agentboard__permission_prompt" ]
|
|
162
|
+
env["MCP_TOOL_TIMEOUT"] = ((Agentboard.config.runtime.fetch("permission_timeout_seconds", 600).to_i + 30) * 1000).to_s
|
|
163
|
+
end
|
|
164
|
+
LaunchSpec.new(argv: argv, env: env, stdin_data: input.to_s, chdir: worktree, passthrough_env: %w[ANTHROPIC_API_KEY CLAUDE_CONFIG_DIR])
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def parse_event(raw_event:)
|
|
168
|
+
data = safe_json(raw_event)
|
|
169
|
+
return [ NormalizedEvent.build("agent.execution.output", { "text" => raw_event.to_s.chomp[0, 4000] }) ] unless data.is_a?(Hash)
|
|
170
|
+
|
|
171
|
+
sid = data["session_id"]
|
|
172
|
+
case data["type"]
|
|
173
|
+
when "system"
|
|
174
|
+
if data["subtype"] == "init"
|
|
175
|
+
[ NormalizedEvent.build("agent.session.started", { "model" => data["model"], "cwd" => data["cwd"], "tools_count" => Array(data["tools"]).size }, external_session_id: sid) ]
|
|
176
|
+
else
|
|
177
|
+
[ NormalizedEvent.build("agent.provider.system", { "subtype" => data["subtype"].to_s[0, 64] }, external_session_id: sid) ]
|
|
178
|
+
end
|
|
179
|
+
when "assistant"
|
|
180
|
+
Array(data.dig("message", "content")).filter_map do |block|
|
|
181
|
+
case block["type"]
|
|
182
|
+
when "text" then NormalizedEvent.build("agent.execution.message", { "text" => block["text"].to_s[0, 8000] }, external_session_id: sid)
|
|
183
|
+
when "tool_use"
|
|
184
|
+
input = block["input"].is_a?(Hash) ? block["input"] : {}
|
|
185
|
+
NormalizedEvent.build("agent.tool.started", { "tool" => block["name"], "tool_use_id" => block["id"],
|
|
186
|
+
"target" => (input["file_path"] || input["path"]).to_s[0, 500].presence }.compact, external_session_id: sid)
|
|
187
|
+
end
|
|
188
|
+
# "thinking" blocks are intentionally dropped: AgentBoard does not expose model reasoning.
|
|
189
|
+
end
|
|
190
|
+
when "user"
|
|
191
|
+
Array(data.dig("message", "content")).filter_map do |block|
|
|
192
|
+
next unless block["type"] == "tool_result"
|
|
193
|
+
|
|
194
|
+
NormalizedEvent.build("agent.tool.completed", { "tool_use_id" => block["tool_use_id"], "ok" => !block["is_error"] }, external_session_id: sid)
|
|
195
|
+
end
|
|
196
|
+
when "result"
|
|
197
|
+
usage = data["usage"].is_a?(Hash) ? data["usage"] : {}
|
|
198
|
+
events = []
|
|
199
|
+
Array(data["permission_denials"]).first(20).each do |d|
|
|
200
|
+
events << NormalizedEvent.build("agent.execution.permission_denied", { "tool" => d["tool_name"].to_s[0, 64] }, external_session_id: sid)
|
|
201
|
+
end
|
|
202
|
+
events << NormalizedEvent.build("agent.execution.usage", { "input_tokens" => usage["input_tokens"], "output_tokens" => usage["output_tokens"],
|
|
203
|
+
"reported_cost" => data["total_cost_usd"], "currency" => (data["total_cost_usd"] ? "USD" : nil),
|
|
204
|
+
"num_turns" => data["num_turns"] }.compact,
|
|
205
|
+
external_session_id: sid,
|
|
206
|
+
usage: Usage.new(input_tokens: usage["input_tokens"], output_tokens: usage["output_tokens"],
|
|
207
|
+
reported_cost: data["total_cost_usd"], currency: data["total_cost_usd"] ? "USD" : nil,
|
|
208
|
+
metering_kind: "unknown"))
|
|
209
|
+
ok = data["subtype"] == "success" && !data["is_error"]
|
|
210
|
+
events << NormalizedEvent.build("agent.execution.result", { "ok" => ok, "subtype" => data["subtype"], "summary" => data["result"].to_s[0, 4000] },
|
|
211
|
+
external_session_id: sid, terminal: true, status: ok ? "succeeded" : "failed")
|
|
212
|
+
events
|
|
213
|
+
else
|
|
214
|
+
[ NormalizedEvent.build("agent.provider.unknown", { "raw_type" => data["type"].to_s[0, 64] }, external_session_id: sid) ]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
module Providers
|
|
3
|
+
# OpenAI Codex adapter.
|
|
4
|
+
#
|
|
5
|
+
# Passive: registers the MCP server through Codex's own interface
|
|
6
|
+
# (`codex mcp add agentboard -- bundle exec agentboard mcp serve --root ROOT`), so
|
|
7
|
+
# AgentBoard never rewrites ~/.codex/config.toml. Codex exposes no general lifecycle
|
|
8
|
+
# hooks to AgentBoard here, so passive tracking relies on explicit MCP activity and Git
|
|
9
|
+
# observation, labeled with provenance. MCP alone is not comprehensive tracking.
|
|
10
|
+
#
|
|
11
|
+
# Active: `codex exec --json --cd WORKTREE --sandbox workspace-write -` with the prompt
|
|
12
|
+
# on stdin. The App Server protocol is deliberately not used until fixture-backed tests
|
|
13
|
+
# exist for a pinned version (spec §26.3).
|
|
14
|
+
class Codex < Base
|
|
15
|
+
SANDBOXES = %w[read-only workspace-write].freeze
|
|
16
|
+
|
|
17
|
+
def self.key = :codex
|
|
18
|
+
def self.display_name = "OpenAI Codex"
|
|
19
|
+
def self.binary = "codex"
|
|
20
|
+
|
|
21
|
+
def probe(environment: ENV)
|
|
22
|
+
path = which("codex")
|
|
23
|
+
return ProbeResult.new(provider: key, installed: false, supported_capabilities: [], validation: "not installed",
|
|
24
|
+
diagnostics: [ "`codex` binary not found on PATH" ]) unless path
|
|
25
|
+
|
|
26
|
+
version = run_version([ path, "--version" ]).to_s[/\d+\.\d+\.\d+/]
|
|
27
|
+
all = %i[mcp active_execution structured_events streaming cancellation session_resume]
|
|
28
|
+
caps, validation, diags = negotiate(version, all, safe_caps: %i[mcp])
|
|
29
|
+
ProbeResult.new(provider: key, installed: true, version: version, authenticated: nil, supported_capabilities: caps,
|
|
30
|
+
validation: validation,
|
|
31
|
+
diagnostics: diags + [ "Passive tracking for Codex is limited to MCP activity and Git observation." ])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def mcp_command(root:) = [ "bundle", "exec", "agentboard", "mcp", "serve", "--root", root.to_s ]
|
|
35
|
+
|
|
36
|
+
def config_toml_path = File.join(ENV.fetch("CODEX_HOME", File.join(Dir.home, ".codex")), "config.toml")
|
|
37
|
+
|
|
38
|
+
# Codex calls the `notify` program with a JSON argument after each agent turn. It is a
|
|
39
|
+
# global, top-level key, so AgentBoard only adds it when explicitly requested and when no
|
|
40
|
+
# other notify program is configured; the managed block is removed by `provider remove`.
|
|
41
|
+
def notify_command(root)
|
|
42
|
+
[ "/usr/bin/env", "BUNDLE_GEMFILE=#{File.join(root, "Gemfile")}", RbConfig.ruby, "-rbundler/setup",
|
|
43
|
+
File.expand_path("../../../exe/agentboard", __dir__), "hook", "ingest", "--provider", "codex", "--event", "agent-turn-complete", "--root", root.to_s ]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def install_notify!(root)
|
|
47
|
+
path = config_toml_path
|
|
48
|
+
content = File.exist?(path) ? File.read(path) : ""
|
|
49
|
+
top_level = content.split(/^\s*\[/, 2).first.to_s
|
|
50
|
+
if top_level.match?(/^\s*notify\s*=/) && !content.include?(ConfigEditor::MARK_BEGIN)
|
|
51
|
+
return [ { path: path, action: "skipped", reason: "an existing notify program is configured" }, nil ]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
backup = ConfigEditor.backup!(path)
|
|
55
|
+
change = ConfigEditor.upsert_block!(path, "notify = #{JSON.generate(notify_command(root))}", position: :start)
|
|
56
|
+
[ change, backup ]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def install_passive_hooks(project:, root:, notify: false)
|
|
60
|
+
path = which("codex") or raise Error.provider_unavailable("codex binary not found")
|
|
61
|
+
list_out, _e, _s = Open3.capture3(Subprocess.scrubbed_env, path, "mcp", "list")
|
|
62
|
+
if list_out.to_s.match?(/^\s*agentboard\b/)
|
|
63
|
+
changes = [ { path: "codex mcp", keys: [ "agentboard" ], action: "unchanged" } ]
|
|
64
|
+
else
|
|
65
|
+
out, err, st = Open3.capture3(Subprocess.scrubbed_env, path, "mcp", "add", "agentboard", "--", *mcp_command(root: root))
|
|
66
|
+
raise Error.provider_unavailable("codex mcp add failed: #{Redactor.redact(err.presence || out)[0, 300]}") unless st.success?
|
|
67
|
+
|
|
68
|
+
changes = [ { path: "codex mcp", keys: [ "agentboard" ], action: "added", command: mcp_command(root: root).join(" ") } ]
|
|
69
|
+
end
|
|
70
|
+
backups = []
|
|
71
|
+
warnings = []
|
|
72
|
+
if notify
|
|
73
|
+
change, backup = install_notify!(root)
|
|
74
|
+
changes << change
|
|
75
|
+
backups << backup if backup
|
|
76
|
+
else
|
|
77
|
+
warnings << "Codex notify hook not installed (pass --notify); activity is tracked through MCP calls and Git evidence."
|
|
78
|
+
end
|
|
79
|
+
manifest = { "provider" => key, "codex_mcp_name" => "agentboard", "notify_config" => (notify ? config_toml_path : nil) }.compact
|
|
80
|
+
ConfigEditor.write_manifest!(root, key, manifest)
|
|
81
|
+
SetupResult.new(provider: key, changes: changes, manifest: manifest, backups: backups, warnings: warnings)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def remove_passive_hooks(project:, root:, manifest: nil)
|
|
85
|
+
removed = []
|
|
86
|
+
manifest ||= ConfigEditor.read_manifest(root, key) || {}
|
|
87
|
+
removed << manifest["notify_config"] if manifest["notify_config"] && ConfigEditor.remove_block!(manifest["notify_config"])
|
|
88
|
+
if (path = which("codex"))
|
|
89
|
+
_o, _e, st = Open3.capture3(Subprocess.scrubbed_env, path, "mcp", "remove", "agentboard")
|
|
90
|
+
removed << "codex mcp agentboard" if st.success?
|
|
91
|
+
end
|
|
92
|
+
removed
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Hook-style payloads (e.g. from a `notify` program) are accepted if an operator wires one.
|
|
96
|
+
def parse_hook(event_name:, payload:)
|
|
97
|
+
data = payload.is_a?(Hash) ? payload : {}
|
|
98
|
+
type = data["type"] == "agent-turn-complete" ? "agent.session.turn_completed" : "agent.provider.unknown"
|
|
99
|
+
summary = data["last-assistant-message"].to_s[0, 500].presence
|
|
100
|
+
[ NormalizedEvent.build(type, { "cwd" => data["cwd"], "turn_id" => data["turn-id"], "summary" => summary }.compact,
|
|
101
|
+
external_session_id: data["thread-id"]) ]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def launch_spec(execution:, worktree:, context_path:, prompt:)
|
|
105
|
+
config = execution.agent_profile&.config || {}
|
|
106
|
+
container = execution.metadata["isolation"] == "container"
|
|
107
|
+
# Codex's own sandbox (Seatbelt/Landlock) is unavailable inside Docker; the container is the boundary.
|
|
108
|
+
sandbox = config.fetch("sandbox", container ? "danger-full-access" : "workspace-write").to_s
|
|
109
|
+
allowed = container ? SANDBOXES + %w[danger-full-access] : SANDBOXES
|
|
110
|
+
raise Error.policy_denied("Codex sandbox #{sandbox} is not allowed#{" outside a container" unless container}") unless allowed.include?(sandbox)
|
|
111
|
+
|
|
112
|
+
argv = [ "codex", "exec", "--json", "--cd", worktree.to_s, "--sandbox", sandbox, "--skip-git-repo-check" ]
|
|
113
|
+
argv += [ "--model", config["model"].to_s ] if config["model"].to_s.match?(/\A[\w.\-:]+\z/)
|
|
114
|
+
argv << "-"
|
|
115
|
+
# `codex exec` authenticates API keys via CODEX_API_KEY; map it from OPENAI_API_KEY.
|
|
116
|
+
LaunchSpec.new(argv: argv, env: {}, stdin_data: prompt, chdir: worktree, passthrough_env: %w[OPENAI_API_KEY CODEX_API_KEY CODEX_HOME],
|
|
117
|
+
credential_aliases: container ? { "CODEX_API_KEY" => "OPENAI_API_KEY" } : nil)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def resume_spec(execution:, worktree:, input:)
|
|
121
|
+
tid = execution.metadata["provider_session_id"].to_s
|
|
122
|
+
raise UnsupportedCapability.new(:session_resume, provider: key) unless tid.match?(/\A[\w-]{8,}\z/) && supports?(:session_resume)
|
|
123
|
+
|
|
124
|
+
LaunchSpec.new(argv: [ "codex", "exec", "--json", "--cd", worktree.to_s, "resume", tid, "-" ], env: {}, stdin_data: input.to_s,
|
|
125
|
+
chdir: worktree, passthrough_env: %w[OPENAI_API_KEY CODEX_HOME])
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def parse_event(raw_event:)
|
|
129
|
+
data = safe_json(raw_event)
|
|
130
|
+
return [ NormalizedEvent.build("agent.execution.output", { "text" => raw_event.to_s.chomp[0, 4000] }) ] unless data.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
case data["type"]
|
|
133
|
+
when "thread.started" then [ NormalizedEvent.build("agent.session.started", {}, external_session_id: data["thread_id"]) ]
|
|
134
|
+
when "turn.started" then [ NormalizedEvent.build("agent.execution.turn_started", {}) ]
|
|
135
|
+
when "item.started", "item.updated", "item.completed" then parse_item(data)
|
|
136
|
+
when "turn.completed"
|
|
137
|
+
u = data["usage"].is_a?(Hash) ? data["usage"] : {}
|
|
138
|
+
[ NormalizedEvent.build("agent.execution.usage", u.slice("input_tokens", "cached_input_tokens", "output_tokens"),
|
|
139
|
+
usage: Usage.new(input_tokens: u["input_tokens"], output_tokens: u["output_tokens"], metering_kind: "unknown")),
|
|
140
|
+
NormalizedEvent.build("agent.execution.result", { "ok" => true }, terminal: true, status: "succeeded") ]
|
|
141
|
+
when "turn.failed"
|
|
142
|
+
[ NormalizedEvent.build("agent.execution.result", { "ok" => false, "error" => data.dig("error", "message").to_s[0, 2000] }, terminal: true, status: "failed") ]
|
|
143
|
+
when "error"
|
|
144
|
+
[ NormalizedEvent.build("agent.execution.error", { "message" => data["message"].to_s[0, 2000] }) ]
|
|
145
|
+
else
|
|
146
|
+
[ NormalizedEvent.build("agent.provider.unknown", { "raw_type" => data["type"].to_s[0, 64] }) ]
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def parse_item(data)
|
|
153
|
+
item = data["item"].is_a?(Hash) ? data["item"] : {}
|
|
154
|
+
done = data["type"] == "item.completed"
|
|
155
|
+
case item["type"]
|
|
156
|
+
when "agent_message"
|
|
157
|
+
done ? [ NormalizedEvent.build("agent.execution.message", { "text" => item["text"].to_s[0, 8000] }) ] : []
|
|
158
|
+
when "reasoning" then [] # private model reasoning is never surfaced
|
|
159
|
+
when "command_execution"
|
|
160
|
+
payload = { "tool" => "shell", "command_excerpt" => item["command"].to_s[0, 160], "exit_code" => item["exit_code"], "status" => item["status"] }.compact
|
|
161
|
+
[ NormalizedEvent.build(done ? "agent.tool.completed" : "agent.tool.started", payload) ]
|
|
162
|
+
when "file_change"
|
|
163
|
+
files = Array(item["changes"]).map { |c| c["path"].to_s[0, 300] }.first(50)
|
|
164
|
+
[ NormalizedEvent.build("agent.tool.completed", { "tool" => "file_change", "files" => files, "status" => item["status"] }.compact) ]
|
|
165
|
+
when "mcp_tool_call"
|
|
166
|
+
[ NormalizedEvent.build(done ? "agent.tool.completed" : "agent.tool.started", { "tool" => "mcp:#{item["server"]}/#{item["tool"]}" }) ]
|
|
167
|
+
when "error"
|
|
168
|
+
done ? [ NormalizedEvent.build("agent.execution.error", { "message" => item["message"].to_s[0, 2000] }) ] : []
|
|
169
|
+
when "todo_list", "web_search"
|
|
170
|
+
[ NormalizedEvent.build("agent.execution.activity", { "kind" => item["type"] }) ]
|
|
171
|
+
else
|
|
172
|
+
[ NormalizedEvent.build("agent.provider.unknown", { "raw_type" => "item:#{item["type"]}".to_s[0, 64] }) ]
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Provider compatibility manifest (spec §26.5).
|
|
2
|
+
# status:
|
|
3
|
+
# smoke_tested – real end-to-end run through AgentBoard (worktree → stream → snapshot →
|
|
4
|
+
# verification → review) passed; sanitized real output kept as a fixture
|
|
5
|
+
# flags_checked – CLI flags verified against `--help`; parsing covered by fixtures only
|
|
6
|
+
provider: claude
|
|
7
|
+
adapter_version: 1
|
|
8
|
+
validated:
|
|
9
|
+
- binary_version: "2.1.325"
|
|
10
|
+
status: smoke_tested
|
|
11
|
+
smoke_test: "2026-11-14 · haiku · implement ticket in Rails app · checks passed at snapshot SHA"
|
|
12
|
+
capabilities: [mcp, passive_hooks, active_execution, structured_events, streaming, cancellation, session_resume, permission_requests]
|
|
13
|
+
fixtures: test/fixtures/providers/claude_code
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
provider: codex
|
|
2
|
+
adapter_version: 1
|
|
3
|
+
validated:
|
|
4
|
+
- binary_version: "0.156.1"
|
|
5
|
+
status: flags_checked
|
|
6
|
+
# Real `codex exec --json` output captured; the run was rejected by the account's model
|
|
7
|
+
# policy (model_not_found), so only the failure path is validated against real output.
|
|
8
|
+
real_fixtures: [test/fixtures/providers/codex/real_0.156.1_model_rejected.jsonl]
|
|
9
|
+
capabilities: [mcp, active_execution, structured_events, streaming, cancellation]
|
|
10
|
+
fixtures: test/fixtures/providers/codex
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
module Providers
|
|
3
|
+
# Minimal, reversible edits to provider configuration files (spec §31.2):
|
|
4
|
+
# back up first, change only AgentBoard-owned entries, never replace a whole file,
|
|
5
|
+
# and record a manifest so `provider remove` can undo exactly what was added.
|
|
6
|
+
module ConfigEditor
|
|
7
|
+
MARK_BEGIN = "# >>> agentboard (managed block — do not edit) >>>".freeze
|
|
8
|
+
MARK_END = "# <<< agentboard <<<".freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def backup!(path)
|
|
13
|
+
return nil unless File.exist?(path)
|
|
14
|
+
|
|
15
|
+
dest = "#{path}.agentboard-backup-#{Time.now.utc.strftime("%Y%m%d%H%M%S")}"
|
|
16
|
+
FileUtils.cp(path, dest, preserve: true)
|
|
17
|
+
dest
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def read_json(path)
|
|
21
|
+
return {} unless File.exist?(path)
|
|
22
|
+
|
|
23
|
+
data = JSON.parse(File.read(path))
|
|
24
|
+
raise Error.validation_failed("#{path} must contain a JSON object") unless data.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
data
|
|
27
|
+
rescue JSON::ParserError => e
|
|
28
|
+
raise Error.validation_failed("#{path} is not valid JSON (#{e.message[0, 80]}); refusing to modify it")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write_json(path, data)
|
|
32
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
33
|
+
tmp = "#{path}.tmp-#{Process.pid}"
|
|
34
|
+
File.write(tmp, JSON.pretty_generate(data) + "\n")
|
|
35
|
+
File.rename(tmp, path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Sets data[*keys] = value only if absent or already AgentBoard-owned. Returns change hash.
|
|
39
|
+
def merge_json!(path, keys, value, owned: ->(existing) { existing == value })
|
|
40
|
+
data = read_json(path)
|
|
41
|
+
parent = keys[0..-2].reduce(data) { |h, k| h[k] ||= {} }
|
|
42
|
+
existing = parent[keys.last]
|
|
43
|
+
return { path: path, keys: keys, action: "unchanged" } if existing == value
|
|
44
|
+
if existing && !owned.call(existing)
|
|
45
|
+
return { path: path, keys: keys, action: "skipped", reason: "an existing non-AgentBoard entry is present" }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
parent[keys.last] = value
|
|
49
|
+
write_json(path, data)
|
|
50
|
+
{ path: path, keys: keys, action: existing ? "updated" : "added" }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def delete_json!(path, keys)
|
|
54
|
+
data = read_json(path)
|
|
55
|
+
parent = keys[0..-2].reduce(data) { |h, k| h.is_a?(Hash) ? h[k] : nil }
|
|
56
|
+
return false unless parent.is_a?(Hash) && parent.key?(keys.last)
|
|
57
|
+
|
|
58
|
+
parent.delete(keys.last)
|
|
59
|
+
write_json(path, data)
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Inserts/replaces a marked text block (used for TOML where no parser is bundled).
|
|
64
|
+
def upsert_block!(path, body, position: :end)
|
|
65
|
+
content = File.exist?(path) ? File.read(path) : +""
|
|
66
|
+
block = "#{MARK_BEGIN}\n#{body.strip}\n#{MARK_END}\n"
|
|
67
|
+
if content.include?(MARK_BEGIN)
|
|
68
|
+
new_content = content.sub(/#{Regexp.escape(MARK_BEGIN)}.*?#{Regexp.escape(MARK_END)}\n?/m, block)
|
|
69
|
+
action = new_content == content ? "unchanged" : "updated"
|
|
70
|
+
else
|
|
71
|
+
new_content = position == :start ? block + "\n" + content : content + (content.end_with?("\n") || content.empty? ? "" : "\n") + "\n" + block
|
|
72
|
+
action = "added"
|
|
73
|
+
end
|
|
74
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
75
|
+
File.write(path, new_content) unless action == "unchanged"
|
|
76
|
+
{ path: path, action: action }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def remove_block!(path)
|
|
80
|
+
return false unless File.exist?(path)
|
|
81
|
+
|
|
82
|
+
content = File.read(path)
|
|
83
|
+
new_content = content.sub(/\n?#{Regexp.escape(MARK_BEGIN)}.*?#{Regexp.escape(MARK_END)}\n?/m, "\n")
|
|
84
|
+
return false if new_content == content
|
|
85
|
+
|
|
86
|
+
File.write(path, new_content)
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def manifest_path(root, provider) = File.join(root.to_s, ".agentboard", "manifests", "#{provider}.json")
|
|
91
|
+
|
|
92
|
+
def write_manifest!(root, provider, manifest)
|
|
93
|
+
path = manifest_path(root, provider)
|
|
94
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
95
|
+
File.write(path, JSON.pretty_generate(manifest.merge("written_at" => Time.now.utc.iso8601)))
|
|
96
|
+
path
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def read_manifest(root, provider)
|
|
100
|
+
path = manifest_path(root, provider)
|
|
101
|
+
File.exist?(path) ? JSON.parse(File.read(path)) : nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
require "fileutils"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Agentboard
|
|
2
|
+
module Providers
|
|
3
|
+
# Cursor: passive-first (spec §26.4). Registers the MCP server in `.cursor/mcp.json`.
|
|
4
|
+
# Active launch/resume stay disabled until a tested provider interface exists.
|
|
5
|
+
class Cursor < Base
|
|
6
|
+
def self.key = :cursor
|
|
7
|
+
def self.display_name = "Cursor"
|
|
8
|
+
|
|
9
|
+
def probe(environment: ENV)
|
|
10
|
+
bin = which("cursor-agent") || which("cursor")
|
|
11
|
+
version = bin && run_version([ bin, "--version" ]).to_s[/\d+\.\d+\.\d+/]
|
|
12
|
+
ProbeResult.new(provider: key, installed: !bin.nil?, version: version, authenticated: nil,
|
|
13
|
+
supported_capabilities: %i[mcp], validation: bin ? "passive only" : "not installed",
|
|
14
|
+
diagnostics: [ "Cursor active execution is not enabled: no fixture-validated agent interface." ])
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def install_passive_hooks(project:, root:)
|
|
18
|
+
path = File.join(root, ".cursor", "mcp.json")
|
|
19
|
+
backups = [ ConfigEditor.backup!(path) ].compact
|
|
20
|
+
entry = { "command" => "bundle", "args" => [ "exec", "agentboard", "mcp", "serve", "--root", root.to_s ] }
|
|
21
|
+
change = ConfigEditor.merge_json!(path, %w[mcpServers agentboard], entry, owned: ->(e) { e.is_a?(Hash) && Array(e["args"]).include?("agentboard") })
|
|
22
|
+
manifest = { "provider" => key, "mcp" => { "path" => path, "keys" => %w[mcpServers agentboard] } }
|
|
23
|
+
ConfigEditor.write_manifest!(root, key, manifest)
|
|
24
|
+
SetupResult.new(provider: key, changes: [ change ], manifest: manifest, backups: backups, warnings: [ "Cursor hooks are not installed." ])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def remove_passive_hooks(project:, root:, manifest: nil)
|
|
28
|
+
m = (manifest || ConfigEditor.read_manifest(root, key) || {})["mcp"]
|
|
29
|
+
m && ConfigEditor.delete_json!(m["path"], m["keys"]) ? [ m["path"] ] : []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parse_hook(event_name:, payload:)
|
|
33
|
+
[ NormalizedEvent.build("agent.provider.unknown", { "event" => event_name.to_s[0, 64] }) ]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def launch_spec(**) = raise(UnsupportedCapability.new(:active_execution, provider: key))
|
|
37
|
+
def parse_event(raw_event:) = [ NormalizedEvent.build("agent.execution.output", { "text" => raw_event.to_s[0, 4000] }) ]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require "rbconfig"
|
|
2
|
+
|
|
3
|
+
module Agentboard
|
|
4
|
+
module Providers
|
|
5
|
+
# Deterministic provider for tests and demos (spec §35.3.4). It launches a small Ruby
|
|
6
|
+
# script that emits JSONL events and edits files in the worktree. Never calls a paid API.
|
|
7
|
+
#
|
|
8
|
+
# Scenarios (profile config `scenario`): success, fail, slow, permission, secret, malformed, noop
|
|
9
|
+
class Fake < Base
|
|
10
|
+
SCRIPT = File.expand_path("fake_agent_script.rb", __dir__)
|
|
11
|
+
|
|
12
|
+
def self.key = :fake
|
|
13
|
+
def self.display_name = "Fake agent (testing)"
|
|
14
|
+
|
|
15
|
+
def probe(environment: ENV)
|
|
16
|
+
ProbeResult.new(provider: key, installed: true, version: "fake-#{Agentboard::VERSION}", authenticated: true,
|
|
17
|
+
supported_capabilities: CAPABILITIES - [ :passive_hooks ], validation: "built-in", diagnostics: [])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def launch_spec(execution:, worktree:, context_path:, prompt:)
|
|
21
|
+
scenario = (execution.agent_profile&.config || {}).fetch("scenario", execution.metadata["scenario"] || "success").to_s
|
|
22
|
+
raise Error.validation_failed("Unknown fake scenario") unless scenario.match?(/\A[a-z_]+\z/)
|
|
23
|
+
|
|
24
|
+
container = execution.metadata["isolation"] == "container"
|
|
25
|
+
LaunchSpec.new(argv: [ container ? "ruby" : RbConfig.ruby, SCRIPT, "--scenario", scenario ],
|
|
26
|
+
env: { "AGENTBOARD_TICKET" => execution.ticket.key }, stdin_data: prompt, chdir: worktree,
|
|
27
|
+
mounts: container ? [ File.dirname(SCRIPT) ] : nil)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def resume_spec(execution:, worktree:, input:)
|
|
31
|
+
sid = execution.metadata["provider_session_id"].to_s
|
|
32
|
+
raise UnsupportedCapability.new(:session_resume, provider: key) unless sid.start_with?("fake-")
|
|
33
|
+
|
|
34
|
+
LaunchSpec.new(argv: [ RbConfig.ruby, SCRIPT, "--scenario", "resume", "--session", sid ],
|
|
35
|
+
env: { "AGENTBOARD_TICKET" => execution.ticket.key }, stdin_data: input.to_s, chdir: worktree)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse_event(raw_event:)
|
|
39
|
+
data = safe_json(raw_event)
|
|
40
|
+
return [ NormalizedEvent.build("agent.execution.output", { "text" => raw_event.to_s.chomp[0, 4000] }) ] unless data.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
case data["type"]
|
|
43
|
+
when "session" then [ NormalizedEvent.build("agent.session.started", data.slice("model"), external_session_id: data["session_id"]) ]
|
|
44
|
+
when "message" then [ NormalizedEvent.build("agent.execution.message", { "text" => data["text"].to_s[0, 8000] }) ]
|
|
45
|
+
when "tool_start" then [ NormalizedEvent.build("agent.tool.started", data.slice("tool", "target")) ]
|
|
46
|
+
when "tool_end" then [ NormalizedEvent.build("agent.tool.completed", data.slice("tool", "target", "ok")) ]
|
|
47
|
+
when "permission" then [ NormalizedEvent.build("agent.execution.permission_requested", data.slice("tool", "reason"), status: "waiting_for_permission") ]
|
|
48
|
+
when "usage"
|
|
49
|
+
[ NormalizedEvent.build("agent.execution.usage", data.slice("input_tokens", "output_tokens"),
|
|
50
|
+
usage: Usage.new(input_tokens: data["input_tokens"], output_tokens: data["output_tokens"], metering_kind: "unknown")) ]
|
|
51
|
+
when "result"
|
|
52
|
+
[ NormalizedEvent.build("agent.execution.result", data.slice("ok", "summary"), terminal: true, status: data["ok"] ? "succeeded" : "failed") ]
|
|
53
|
+
else
|
|
54
|
+
[ NormalizedEvent.build("agent.provider.unknown", { "raw_type" => data["type"].to_s[0, 64] }) ]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|