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.
Files changed (159) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +62 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +316 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/agentboard/application.css +510 -0
  7. data/app/controllers/agentboard/agents_controller.rb +10 -0
  8. data/app/controllers/agentboard/analytics_controller.rb +37 -0
  9. data/app/controllers/agentboard/application_controller.rb +90 -0
  10. data/app/controllers/agentboard/approvals_controller.rb +15 -0
  11. data/app/controllers/agentboard/assets_controller.rb +32 -0
  12. data/app/controllers/agentboard/board_controller.rb +46 -0
  13. data/app/controllers/agentboard/captures_controller.rb +17 -0
  14. data/app/controllers/agentboard/comments_controller.rb +9 -0
  15. data/app/controllers/agentboard/criteria_controller.rb +17 -0
  16. data/app/controllers/agentboard/dependencies_controller.rb +20 -0
  17. data/app/controllers/agentboard/executions_controller.rb +48 -0
  18. data/app/controllers/agentboard/milestones_controller.rb +23 -0
  19. data/app/controllers/agentboard/pulse_controller.rb +12 -0
  20. data/app/controllers/agentboard/sessions_controller.rb +19 -0
  21. data/app/controllers/agentboard/settings_controller.rb +9 -0
  22. data/app/controllers/agentboard/tickets_controller.rb +139 -0
  23. data/app/controllers/agentboard/workflow_runs_controller.rb +16 -0
  24. data/app/controllers/agentboard/workflows_controller.rb +62 -0
  25. data/app/helpers/agentboard/application_helper.rb +196 -0
  26. data/app/javascript/agentboard/application.js +227 -0
  27. data/app/javascript/agentboard/capture.js +299 -0
  28. data/app/jobs/agentboard/application_job.rb +4 -0
  29. data/app/models/agentboard/acceptance_criterion.rb +23 -0
  30. data/app/models/agentboard/agent_profile.rb +16 -0
  31. data/app/models/agentboard/agent_session.rb +30 -0
  32. data/app/models/agentboard/application_record.rb +13 -0
  33. data/app/models/agentboard/approval.rb +29 -0
  34. data/app/models/agentboard/artifact.rb +11 -0
  35. data/app/models/agentboard/comment.rb +9 -0
  36. data/app/models/agentboard/event.rb +37 -0
  37. data/app/models/agentboard/execution.rb +51 -0
  38. data/app/models/agentboard/idempotency_key.rb +5 -0
  39. data/app/models/agentboard/milestone.rb +7 -0
  40. data/app/models/agentboard/outbox_message.rb +11 -0
  41. data/app/models/agentboard/project.rb +43 -0
  42. data/app/models/agentboard/repository_snapshot.rb +5 -0
  43. data/app/models/agentboard/session_ticket_link.rb +18 -0
  44. data/app/models/agentboard/ticket.rb +81 -0
  45. data/app/models/agentboard/ticket_dependency.rb +34 -0
  46. data/app/models/agentboard/ticket_lease.rb +11 -0
  47. data/app/models/agentboard/usage_record.rb +10 -0
  48. data/app/models/agentboard/verification_result.rb +12 -0
  49. data/app/models/agentboard/webhook_delivery.rb +7 -0
  50. data/app/models/agentboard/workflow_definition.rb +20 -0
  51. data/app/models/agentboard/workflow_run.rb +21 -0
  52. data/app/models/agentboard/workflow_step.rb +12 -0
  53. data/app/services/agentboard/approvals.rb +76 -0
  54. data/app/services/agentboard/captures.rb +113 -0
  55. data/app/services/agentboard/completion_policy.rb +117 -0
  56. data/app/services/agentboard/current.rb +5 -0
  57. data/app/services/agentboard/data_governance.rb +68 -0
  58. data/app/services/agentboard/doctor.rb +133 -0
  59. data/app/services/agentboard/event_store.rb +88 -0
  60. data/app/services/agentboard/evidence.rb +52 -0
  61. data/app/services/agentboard/executions.rb +198 -0
  62. data/app/services/agentboard/hook_ingestion.rb +100 -0
  63. data/app/services/agentboard/idempotency.rb +28 -0
  64. data/app/services/agentboard/lease_service.rb +107 -0
  65. data/app/services/agentboard/mcp_server.rb +138 -0
  66. data/app/services/agentboard/mcp_tools.rb +341 -0
  67. data/app/services/agentboard/outbox.rb +44 -0
  68. data/app/services/agentboard/pull_requests.rb +65 -0
  69. data/app/services/agentboard/runtime/execution_runner.rb +352 -0
  70. data/app/services/agentboard/runtime/ipc_handler.rb +55 -0
  71. data/app/services/agentboard/runtime/reconciler.rb +42 -0
  72. data/app/services/agentboard/runtime/supervisor.rb +96 -0
  73. data/app/services/agentboard/session_association.rb +44 -0
  74. data/app/services/agentboard/session_correlation.rb +56 -0
  75. data/app/services/agentboard/session_credential.rb +44 -0
  76. data/app/services/agentboard/ticket_advisor.rb +60 -0
  77. data/app/services/agentboard/tickets.rb +218 -0
  78. data/app/services/agentboard/verification_service.rb +60 -0
  79. data/app/services/agentboard/workflow_engine.rb +261 -0
  80. data/app/views/agentboard/agents/index.html.erb +51 -0
  81. data/app/views/agentboard/analytics/show.html.erb +78 -0
  82. data/app/views/agentboard/approvals/_approval.html.erb +27 -0
  83. data/app/views/agentboard/approvals/index.html.erb +31 -0
  84. data/app/views/agentboard/board/_card.html.erb +36 -0
  85. data/app/views/agentboard/board/show.html.erb +73 -0
  86. data/app/views/agentboard/executions/index.html.erb +33 -0
  87. data/app/views/agentboard/executions/show.html.erb +126 -0
  88. data/app/views/agentboard/milestones/index.html.erb +28 -0
  89. data/app/views/agentboard/settings/show.html.erb +33 -0
  90. data/app/views/agentboard/shared/_dag.html.erb +24 -0
  91. data/app/views/agentboard/tickets/_form.html.erb +38 -0
  92. data/app/views/agentboard/tickets/edit.html.erb +5 -0
  93. data/app/views/agentboard/tickets/index.html.erb +51 -0
  94. data/app/views/agentboard/tickets/new.html.erb +6 -0
  95. data/app/views/agentboard/tickets/show.html.erb +343 -0
  96. data/app/views/agentboard/tickets/suggestions.html.erb +21 -0
  97. data/app/views/agentboard/workflow_runs/show.html.erb +28 -0
  98. data/app/views/agentboard/workflows/index.html.erb +49 -0
  99. data/app/views/agentboard/workflows/show.html.erb +15 -0
  100. data/app/views/layouts/agentboard/application.html.erb +93 -0
  101. data/config/routes.rb +49 -0
  102. data/db/migrate/20261114000001_create_agentboard_core.rb +144 -0
  103. data/db/migrate/20261114000002_create_agentboard_runtime.rb +213 -0
  104. data/db/migrate/20261115000001_create_agentboard_webhook_deliveries.rb +18 -0
  105. data/exe/agentboard +7 -0
  106. data/lib/agentboard/agent_container.rb +124 -0
  107. data/lib/agentboard/authorization.rb +58 -0
  108. data/lib/agentboard/capture/middleware.rb +54 -0
  109. data/lib/agentboard/capture.rb +57 -0
  110. data/lib/agentboard/cli.rb +818 -0
  111. data/lib/agentboard/command_policy.rb +44 -0
  112. data/lib/agentboard/configuration.rb +160 -0
  113. data/lib/agentboard/container_sandbox.rb +65 -0
  114. data/lib/agentboard/context_builder.rb +243 -0
  115. data/lib/agentboard/egress_proxy.rb +95 -0
  116. data/lib/agentboard/engine.rb +27 -0
  117. data/lib/agentboard/errors.rb +42 -0
  118. data/lib/agentboard/event_schema.rb +37 -0
  119. data/lib/agentboard/extensions.rb +58 -0
  120. data/lib/agentboard/git/repository.rb +92 -0
  121. data/lib/agentboard/git/worktree_manager.rb +107 -0
  122. data/lib/agentboard/git.rb +4 -0
  123. data/lib/agentboard/ids.rb +33 -0
  124. data/lib/agentboard/json_log_formatter.rb +28 -0
  125. data/lib/agentboard/path_guard.rb +27 -0
  126. data/lib/agentboard/process_ancestry.rb +40 -0
  127. data/lib/agentboard/providers/base.rb +107 -0
  128. data/lib/agentboard/providers/claude_code.rb +219 -0
  129. data/lib/agentboard/providers/codex.rb +177 -0
  130. data/lib/agentboard/providers/compatibility/claude.yml +13 -0
  131. data/lib/agentboard/providers/compatibility/codex.yml +10 -0
  132. data/lib/agentboard/providers/config_editor.rb +106 -0
  133. data/lib/agentboard/providers/cursor.rb +40 -0
  134. data/lib/agentboard/providers/fake.rb +59 -0
  135. data/lib/agentboard/providers/fake_agent_script.rb +47 -0
  136. data/lib/agentboard/providers/generic_cli.rb +35 -0
  137. data/lib/agentboard/providers/registry.rb +31 -0
  138. data/lib/agentboard/providers/types.rb +32 -0
  139. data/lib/agentboard/providers.rb +26 -0
  140. data/lib/agentboard/rails_introspection.rb +49 -0
  141. data/lib/agentboard/redactor.rb +62 -0
  142. data/lib/agentboard/result.rb +46 -0
  143. data/lib/agentboard/runtime/ipc.rb +193 -0
  144. data/lib/agentboard/runtime.rb +1 -0
  145. data/lib/agentboard/state_machines.rb +71 -0
  146. data/lib/agentboard/subprocess.rb +168 -0
  147. data/lib/agentboard/templates/Dockerfile.agent +21 -0
  148. data/lib/agentboard/verification.rb +79 -0
  149. data/lib/agentboard/version.rb +3 -0
  150. data/lib/agentboard/workflows/condition.rb +166 -0
  151. data/lib/agentboard/workflows/definition.rb +208 -0
  152. data/lib/agentboard/workflows.rb +2 -0
  153. data/lib/agentboard.rb +73 -0
  154. data/lib/generators/agentboard/install/install_generator.rb +67 -0
  155. data/lib/generators/agentboard/install/templates/config.yml +81 -0
  156. data/lib/generators/agentboard/install/templates/feature_delivery.yml +25 -0
  157. data/lib/generators/agentboard/install/templates/initializer.rb +23 -0
  158. data/lib/tasks/agentboard_tasks.rake +6 -0
  159. 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