feat(agents): agent platform — registry, activity hooks, harness allowlist (#119)

Introduces the shared platform that per-agent adapters plug into, wired for the
three shipped harnesses (claude-code, codex, opencode):

- adapters/agent/registry: single source of truth for shipped adapters
  (Constructors), consumed by the daemon to resolve a session's harness.
- adapters/agent/activitydispatch + 'ao hooks' command: maps an agent's native
  hook callbacks onto AO activity states (active/idle/waiting/...).
- claudecode/codex/opencode: emit SessionStart/UserPromptSubmit/Stop activity.
- HTTP + OpenAPI: report session activity state.
- db: single migration widening sessions.harness to all shipped harnesses, so
  adding an adapter needs no further migration.
- domain: harness constants + --agent alias for 'ao spawn'.

Adding a new agent is now one adapter package plus a line in Constructors().

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Harshit Singh Bhandari <claudeagain@pkarnal.com>
This commit is contained in:
yyovil 2026-06-07 00:52:40 +05:30 committed by GitHub
parent 3c7344b233
commit 3152cdc948
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 385 additions and 132 deletions

View File

@ -86,6 +86,7 @@ linters:
- G104 # unchecked errors — errcheck owns this - G104 # unchecked errors — errcheck owns this
- G304 # file inclusion via variable — paths are config/run-file/worktree-derived, not user input - G304 # file inclusion via variable — paths are config/run-file/worktree-derived, not user input
- G703 # path traversal via taint analysis — same as G304: binary-resolution and worktree-derived paths, not user input - G703 # path traversal via taint analysis — same as G304: binary-resolution and worktree-derived paths, not user input
- G704 # SSRF via taint analysis — the daemon client's host is hardcoded loopback; only the request path varies, so it cannot be steered to an external host
exclusions: exclusions:
generated: lax # skip sqlc/codegen ("Code generated ... DO NOT EDIT") generated: lax # skip sqlc/codegen ("Code generated ... DO NOT EDIT")

View File

@ -6,8 +6,10 @@ require (
github.com/coder/websocket v1.8.14 github.com/coder/websocket v1.8.14
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0
github.com/pressly/goose/v3 v3.27.1 github.com/pressly/goose/v3 v3.27.1
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.9
github.com/swaggest/jsonschema-go v0.3.79 github.com/swaggest/jsonschema-go v0.3.79
github.com/swaggest/openapi-go v0.2.61 github.com/swaggest/openapi-go v0.2.61
golang.org/x/sys v0.43.0 golang.org/x/sys v0.43.0
@ -17,17 +19,16 @@ require (
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/swaggest/refl v1.4.0 // indirect github.com/swaggest/refl v1.4.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/tools v0.43.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.72.3 // indirect modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

View File

@ -60,14 +60,14 @@ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3Ifn
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=

View File

@ -1,6 +1,11 @@
// Package activitydispatch is the single source of truth mapping the agent // Package activitydispatch is the single source of truth mapping the agent
// token in `ao hooks <agent> <event>` onto the function that interprets that // token in `ao hooks <agent> <event>` onto the function that interprets that
// agent's hook callbacks as an AO activity state. // agent's hook callbacks as an AO activity state.
//
// The hidden `ao hooks` CLI command dispatches a live callback through it. Every
// adapter that installs `ao hooks <tok>` callbacks must have a deriver
// registered here — otherwise the adapter writes callbacks that nothing on the
// receiving side understands, so its activity is silently never reported.
package activitydispatch package activitydispatch
import ( import (
@ -15,6 +20,7 @@ import (
type DeriveFunc func(event string, payload []byte) (domain.ActivityState, bool) type DeriveFunc func(event string, payload []byte) (domain.ActivityState, bool)
// Derivers maps the agent token in `ao hooks <agent> <event>` to its deriver. // Derivers maps the agent token in `ao hooks <agent> <event>` to its deriver.
// Per-adapter PRs add their tokens here as they land.
var Derivers = map[string]DeriveFunc{ var Derivers = map[string]DeriveFunc{
"claude-code": claudecode.DeriveActivityState, "claude-code": claudecode.DeriveActivityState,
"codex": codex.DeriveActivityState, "codex": codex.DeriveActivityState,
@ -22,7 +28,8 @@ var Derivers = map[string]DeriveFunc{
} }
// Derive looks up the deriver for an agent token and applies it. ok=false when // Derive looks up the deriver for an agent token and applies it. ok=false when
// the token has no registered deriver or the event carries no activity signal. // the token has no registered deriver or the event carries no activity signal —
// the caller reports nothing in either case.
func Derive(agent, event string, payload []byte) (domain.ActivityState, bool) { func Derive(agent, event string, payload []byte) (domain.ActivityState, bool) {
derive, found := Derivers[agent] derive, found := Derivers[agent]
if !found { if !found {

View File

@ -6,14 +6,23 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/domain"
) )
// DeriveActivityState maps a Claude Code hook event and its native stdin payload // DeriveActivityState maps a Claude Code hook event (and its native stdin
// onto an AO activity state. The bool is false when the event carries no // payload) onto an AO activity state. The bool is false when the event carries
// activity signal. // no activity signal — e.g. SessionStart (metadata only, v1), a Notification
// type we don't track, or a SessionEnd reason that doesn't actually end the AO
// session — in which case the caller reports nothing.
//
// event is the AO hook sub-command name installed in claudeManagedHooks
// ("user-prompt-submit", "stop", "notification", "session-end", ...), NOT the
// native Claude event name. Keeping this beside hooks.go means the events AO
// installs and what they mean live in one place.
func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bool) { func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bool) {
switch event { switch event {
case "user-prompt-submit": case "user-prompt-submit":
return domain.ActivityActive, true return domain.ActivityActive, true
case "stop": case "stop":
// End of a turn: the agent is idle but alive (not exited). A following
// Notification(idle_prompt) upgrades this to the sticky waiting_input.
return domain.ActivityIdle, true return domain.ActivityIdle, true
case "notification": case "notification":
return notificationState(payload) return notificationState(payload)
@ -24,6 +33,10 @@ func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bo
} }
} }
// notificationState reports waiting_input only for the notification types that
// mean "the agent is blocked on the user": a pending tool-permission prompt or
// an idle prompt awaiting the next instruction. Other types (auth_success,
// elicitation_*) carry no activity meaning, as does a malformed payload.
func notificationState(payload []byte) (domain.ActivityState, bool) { func notificationState(payload []byte) (domain.ActivityState, bool) {
var p struct { var p struct {
NotificationType string `json:"notification_type"` NotificationType string `json:"notification_type"`
@ -37,6 +50,13 @@ func notificationState(payload []byte) (domain.ActivityState, bool) {
} }
} }
// sessionEndState reports exited for reasons that actually end the session.
// clear/resume keep the same AO session alive (a new native session continues
// in the worktree), so they report nothing. Any other reason — logout,
// prompt_input_exit, bypass_permissions_disabled, other, or an absent/unknown
// reason on a SessionEnd that did fire — is treated as a real exit. SessionEnd
// is not guaranteed on crash/SIGKILL, so the reaper remains the backstop; both
// paths guard on IsTerminated, so whichever lands first wins.
func sessionEndState(payload []byte) (domain.ActivityState, bool) { func sessionEndState(payload []byte) (domain.ActivityState, bool) {
var p struct { var p struct {
Reason string `json:"reason"` Reason string `json:"reason"`

View File

@ -19,9 +19,11 @@ func TestDeriveActivityState(t *testing.T) {
{"notification idle_prompt -> waiting_input", "notification", `{"notification_type":"idle_prompt"}`, domain.ActivityWaitingInput, true}, {"notification idle_prompt -> waiting_input", "notification", `{"notification_type":"idle_prompt"}`, domain.ActivityWaitingInput, true},
{"notification permission_prompt -> waiting_input", "notification", `{"notification_type":"permission_prompt"}`, domain.ActivityWaitingInput, true}, {"notification permission_prompt -> waiting_input", "notification", `{"notification_type":"permission_prompt"}`, domain.ActivityWaitingInput, true},
{"notification auth_success -> no signal", "notification", `{"notification_type":"auth_success"}`, "", false}, {"notification auth_success -> no signal", "notification", `{"notification_type":"auth_success"}`, "", false},
{"notification empty type -> no signal", "notification", `{}`, "", false},
{"notification malformed payload -> no signal", "notification", `not json`, "", false}, {"notification malformed payload -> no signal", "notification", `not json`, "", false},
{"session-end logout -> exited", "session-end", `{"reason":"logout"}`, domain.ActivityExited, true}, {"session-end logout -> exited", "session-end", `{"reason":"logout"}`, domain.ActivityExited, true},
{"session-end prompt_input_exit -> exited", "session-end", `{"reason":"prompt_input_exit"}`, domain.ActivityExited, true}, {"session-end prompt_input_exit -> exited", "session-end", `{"reason":"prompt_input_exit"}`, domain.ActivityExited, true},
{"session-end other -> exited", "session-end", `{"reason":"other"}`, domain.ActivityExited, true},
{"session-end absent reason -> exited", "session-end", `{}`, domain.ActivityExited, true}, {"session-end absent reason -> exited", "session-end", `{}`, domain.ActivityExited, true},
{"session-end clear -> no signal", "session-end", `{"reason":"clear"}`, "", false}, {"session-end clear -> no signal", "session-end", `{"reason":"clear"}`, "", false},
{"session-end resume -> no signal", "session-end", `{"reason":"resume"}`, "", false}, {"session-end resume -> no signal", "session-end", `{"reason":"resume"}`, "", false},

View File

@ -37,14 +37,6 @@ const (
// adapterID is the registry id and the value users pass to // adapterID is the registry id and the value users pass to
// `ao spawn --agent`. // `ao spawn --agent`.
adapterID = "claude-code" adapterID = "claude-code"
// Normalized session-metadata keys the Claude Code hooks persist into the
// AO session store and SessionInfo reads back. Shared vocabulary
// with the Codex adapter so the dashboard treats every agent uniformly.
// The native session id key lives in ports as MetadataKeyAgentSessionID
// because the Session Manager also reads it.
claudeTitleMetadataKey = "title"
claudeSummaryMetadataKey = "summary"
) )
// claudeSessionNamespace seeds the UUIDv5 derivation that maps an AO // claudeSessionNamespace seeds the UUIDv5 derivation that maps an AO
@ -220,8 +212,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
} }
info := ports.SessionInfo{ info := ports.SessionInfo{
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
Title: session.Metadata[claudeTitleMetadataKey], Title: session.Metadata[ports.MetadataKeyTitle],
Summary: session.Metadata[claudeSummaryMetadataKey], Summary: session.Metadata[ports.MetadataKeySummary],
} }
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
return ports.SessionInfo{}, false, nil return ports.SessionInfo{}, false, nil

View File

@ -214,8 +214,8 @@ func TestGetAgentHooksInstallsClaudeHooks(t *testing.T) {
if m := matcherForCommand(config.Hooks["UserPromptSubmit"], "ao hooks claude-code user-prompt-submit"); m != nil { if m := matcherForCommand(config.Hooks["UserPromptSubmit"], "ao hooks claude-code user-prompt-submit"); m != nil {
t.Fatalf("UserPromptSubmit matcher = %v, want none", m) t.Fatalf("UserPromptSubmit matcher = %v, want none", m)
} }
// Notification and SessionEnd install with no matcher; the handler filters // Notification and SessionEnd install with no matcher (they fire for all
// on the payload. // sub-types; the handler filters on the payload).
if m := matcherForCommand(config.Hooks["Notification"], "ao hooks claude-code notification"); m != nil { if m := matcherForCommand(config.Hooks["Notification"], "ao hooks claude-code notification"); m != nil {
t.Fatalf("Notification matcher = %v, want none", m) t.Fatalf("Notification matcher = %v, want none", m)
} }
@ -303,8 +303,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
WorkspacePath: "/some/path", WorkspacePath: "/some/path",
Metadata: map[string]string{ Metadata: map[string]string{
ports.MetadataKeyAgentSessionID: "claude-native-1", ports.MetadataKeyAgentSessionID: "claude-native-1",
claudeTitleMetadataKey: "Fix login redirect", ports.MetadataKeyTitle: "Fix login redirect",
claudeSummaryMetadataKey: "Updated the auth callback and tests.", ports.MetadataKeySummary: "Updated the auth callback and tests.",
"ignored": "not returned", "ignored": "not returned",
}, },
}) })

View File

@ -9,6 +9,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
"github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/ports"
) )
@ -56,7 +57,8 @@ var claudeStartupMatcher = "startup"
// activity-state signals back into AO's store (see DeriveActivityState). // activity-state signals back into AO's store (see DeriveActivityState).
// Notification and SessionEnd carry no matcher: each installs once and fires // Notification and SessionEnd carry no matcher: each installs once and fires
// for every sub-type, and the handler filters on the payload's // for every sub-type, and the handler filters on the payload's
// notification_type / reason field. // notification_type / reason field — installing one command under multiple
// matchers would trip the per-command dedup in claudeHookCommandExists.
var claudeManagedHooks = []claudeHookSpec{ var claudeManagedHooks = []claudeHookSpec{
{Event: "SessionStart", Matcher: &claudeStartupMatcher, Command: claudeHookCommandPrefix + "session-start"}, {Event: "SessionStart", Matcher: &claudeStartupMatcher, Command: claudeHookCommandPrefix + "session-start"},
{Event: "UserPromptSubmit", Command: claudeHookCommandPrefix + "user-prompt-submit"}, {Event: "UserPromptSubmit", Command: claudeHookCommandPrefix + "user-prompt-submit"},
@ -233,36 +235,12 @@ func writeClaudeSettings(settingsPath string, topLevel, rawHooks map[string]json
return fmt.Errorf("encode %s: %w", settingsPath, err) return fmt.Errorf("encode %s: %w", settingsPath, err)
} }
data = append(data, '\n') data = append(data, '\n')
if err := atomicWriteFile(settingsPath, data, 0o600); err != nil { if err := hookutil.AtomicWriteFile(settingsPath, data, 0o600); err != nil {
return fmt.Errorf("write %s: %w", settingsPath, err) return fmt.Errorf("write %s: %w", settingsPath, err)
} }
return nil return nil
} }
// atomicWriteFile writes data to path via a temp file in the same directory
// followed by a rename, so a crash or signal mid-write can't leave a truncated
// or empty file that Claude Code then fails to parse (silently disabling hooks).
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
// groupClaudeHooksByEvent groups the managed hook specs by their Claude event so // groupClaudeHooksByEvent groups the managed hook specs by their Claude event so
// each event's settings array is rewritten once. // each event's settings array is rewritten once.
func groupClaudeHooksByEvent() map[string][]claudeHookSpec { func groupClaudeHooksByEvent() map[string][]claudeHookSpec {

View File

@ -4,6 +4,11 @@ import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// DeriveActivityState maps a Codex hook event onto an AO activity state. The // DeriveActivityState maps a Codex hook event onto an AO activity state. The
// bool is false when the event carries no activity signal. // bool is false when the event carries no activity signal.
//
// event is the AO hook sub-command name installed in codexManagedHooks
// ("user-prompt-submit", "permission-request", "stop", ...), not the native
// Codex event name. Codex currently has no SessionEnd/Notification equivalent
// in the adapter, so runtime exit still falls back to the reaper.
func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
switch event { switch event {
case "user-prompt-submit": case "user-prompt-submit":

View File

@ -19,11 +19,6 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/ports"
) )
const (
codexTitleMetadataKey = "title"
codexSummaryMetadataKey = "summary"
)
// Plugin is the Codex agent adapter. It is safe for concurrent use; the binary // Plugin is the Codex agent adapter. It is safe for concurrent use; the binary
// path is resolved once and cached under binaryMu. // path is resolved once and cached under binaryMu.
type Plugin struct { type Plugin struct {
@ -132,8 +127,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
} }
info := ports.SessionInfo{ info := ports.SessionInfo{
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
Title: session.Metadata[codexTitleMetadataKey], Title: session.Metadata[ports.MetadataKeyTitle],
Summary: session.Metadata[codexSummaryMetadataKey], Summary: session.Metadata[ports.MetadataKeySummary],
} }
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
return ports.SessionInfo{}, false, nil return ports.SessionInfo{}, false, nil

View File

@ -298,8 +298,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
WorkspacePath: "/some/path", WorkspacePath: "/some/path",
Metadata: map[string]string{ Metadata: map[string]string{
ports.MetadataKeyAgentSessionID: "thread-123", ports.MetadataKeyAgentSessionID: "thread-123",
codexTitleMetadataKey: "Fix login redirect", ports.MetadataKeyTitle: "Fix login redirect",
codexSummaryMetadataKey: "Updated the auth callback and tests.", ports.MetadataKeySummary: "Updated the auth callback and tests.",
"ignored": "not returned", "ignored": "not returned",
}, },
}) })

View File

@ -9,6 +9,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
"github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/ports"
) )
@ -229,35 +230,12 @@ func writeCodexHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMes
return fmt.Errorf("encode %s: %w", hooksPath, err) return fmt.Errorf("encode %s: %w", hooksPath, err)
} }
data = append(data, '\n') data = append(data, '\n')
if err := atomicWriteFile(hooksPath, data, 0o600); err != nil { if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil {
return fmt.Errorf("write %s: %w", hooksPath, err) return fmt.Errorf("write %s: %w", hooksPath, err)
} }
return nil return nil
} }
// atomicWriteFile writes data to path via a temp file + rename, so a crash mid-
// write can't leave a truncated/empty file that Codex then fails to parse.
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
// groupCodexHooksByEvent groups the managed hook specs by their Codex event so // groupCodexHooksByEvent groups the managed hook specs by their Codex event so
// each event's array is rewritten once. // each event's array is rewritten once.
func groupCodexHooksByEvent() map[string][]codexHookSpec { func groupCodexHooksByEvent() map[string][]codexHookSpec {
@ -382,7 +360,7 @@ func ensureCodexHooksFeatureEnabled(workspacePath string) error {
if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil { if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil {
return fmt.Errorf("create .codex directory: %w", err) return fmt.Errorf("create .codex directory: %w", err)
} }
if err := atomicWriteFile(configPath, []byte(content), 0o600); err != nil { if err := hookutil.AtomicWriteFile(configPath, []byte(content), 0o600); err != nil {
return fmt.Errorf("write config.toml: %w", err) return fmt.Errorf("write config.toml: %w", err)
} }
return nil return nil

View File

@ -0,0 +1,37 @@
// Package hookutil holds small filesystem helpers shared by the agent hook
// installers (claude-code, codex, opencode). It centralizes the atomic-write
// primitive so every adapter writes hook config the same crash-safe way.
package hookutil
import (
"os"
"path/filepath"
)
// AtomicWriteFile writes data to path via a temp file in the same directory
// followed by a rename, so a crash or signal mid-write can't leave a truncated
// or empty file that the agent then fails to parse (silently disabling hooks).
func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}

View File

@ -3,7 +3,10 @@ package opencode
import "github.com/aoagents/agent-orchestrator/backend/internal/domain" import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// DeriveActivityState maps an opencode plugin hook event onto an AO activity // DeriveActivityState maps an opencode plugin hook event onto an AO activity
// state. The bool is false when the event carries no activity signal. // state. The opencode plugin (assets/ao-activity.ts) normalizes opencode's
// native events to "session-start" / "user-prompt-submit" / "stop" before
// invoking `ao hooks opencode <event>`. The bool is false when the event
// carries no activity signal.
func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
switch event { switch event {
case "session-start": case "session-start":

View File

@ -10,6 +10,7 @@ import (
_ "embed" _ "embed"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
"github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/ports"
) )
@ -90,7 +91,7 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi
if err := os.MkdirAll(filepath.Dir(pluginPath), 0o750); err != nil { if err := os.MkdirAll(filepath.Dir(pluginPath), 0o750); err != nil {
return fmt.Errorf("opencode.GetAgentHooks: create plugin dir: %w", err) return fmt.Errorf("opencode.GetAgentHooks: create plugin dir: %w", err)
} }
if err := atomicWriteFile(pluginPath, []byte(opencodePluginSource), 0o600); err != nil { if err := hookutil.AtomicWriteFile(pluginPath, []byte(opencodePluginSource), 0o600); err != nil {
return fmt.Errorf("opencode.GetAgentHooks: write plugin: %w", err) return fmt.Errorf("opencode.GetAgentHooks: write plugin: %w", err)
} }
return nil return nil
@ -155,31 +156,3 @@ func isAOManagedPlugin(path string) (bool, error) {
} }
return strings.Contains(string(data), opencodePluginSentinel), nil return strings.Contains(string(data), opencodePluginSentinel), nil
} }
// atomicWriteFile writes data to path via a temp file + rename, so a crash mid-
// write can't leave a truncated plugin file that opencode then fails to import
// (silently disabling activity reporting).
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}

View File

@ -0,0 +1,67 @@
// Package registry is the single source of truth for the agent adapters the
// daemon ships. The daemon wires sessions through it, so adding a harness is a
// single edit to Constructors rather than a list maintained in several places.
package registry
import (
"fmt"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// Constructors returns a fresh instance of every agent adapter the daemon
// ships, in a stable registration order. Adding a new harness means adding its
// constructor here (and a domain.AgentHarness constant) — the one edit the
// daemon picks up.
func Constructors() []adapters.Adapter {
return []adapters.Adapter{
claudecode.New(),
codex.New(),
opencode.New(),
}
}
// Build returns a registry populated with the shipped agent adapters, keyed by
// manifest id. Registration only fails on an empty/duplicate id — a programmer
// error, not a runtime condition.
func Build() (*adapters.Registry, error) {
reg := adapters.NewRegistry()
for _, a := range Constructors() {
if err := reg.Register(a); err != nil {
return nil, fmt.Errorf("register agent adapter %q: %w", a.Manifest().ID, err)
}
}
return reg, nil
}
// HarnessAgent pairs a session harness with the adapter that drives it. The
// harness is the adapter's manifest id, which is also the domain.AgentHarness
// value a session carries and the `--harness` flag users pass.
type HarnessAgent struct {
Harness domain.AgentHarness
Agent ports.Agent
}
// Harnessed returns every shipped adapter that drives an agent, paired with its
// harness, in Constructors() order. An adapter that does not implement
// ports.Agent is skipped.
func Harnessed() []HarnessAgent {
cons := Constructors()
out := make([]HarnessAgent, 0, len(cons))
for _, a := range cons {
agent, ok := a.(ports.Agent)
if !ok {
continue
}
out = append(out, HarnessAgent{
Harness: domain.AgentHarness(a.Manifest().ID),
Agent: agent,
})
}
return out
}

View File

@ -6,6 +6,7 @@ import (
"io" "io"
"net/url" "net/url"
"os" "os"
"regexp"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -13,6 +14,11 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitydispatch" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitydispatch"
) )
// sessionIDPattern bounds the AO_SESSION_ID we will place in a request path to
// the id alphabet the daemon issues. Validating the externally-set env value
// before it reaches the loopback URL keeps it from steering the request.
var sessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// setActivityAPIRequest mirrors the daemon's SetActivityRequest body for // setActivityAPIRequest mirrors the daemon's SetActivityRequest body for
// POST /api/v1/sessions/{id}/activity. The CLI keeps its own copy so it need // POST /api/v1/sessions/{id}/activity. The CLI keeps its own copy so it need
// not import httpd. // not import httpd.
@ -42,9 +48,10 @@ func newHooksCommand(ctx *commandContext) *cobra.Command {
func (c *commandContext) runHook(ctx context.Context, agent, event string) error { func (c *commandContext) runHook(ctx context.Context, agent, event string) error {
sessionID := strings.TrimSpace(os.Getenv("AO_SESSION_ID")) sessionID := strings.TrimSpace(os.Getenv("AO_SESSION_ID"))
if sessionID == "" { if !sessionIDPattern.MatchString(sessionID) {
// Not an AO-managed session. Return before reading stdin so a manual // Not an AO-managed session (unset/empty), or an id we won't put in a
// invocation without a piped payload can't block on EOF. // request path. Return before reading stdin so a manual invocation
// without a piped payload can't block on EOF.
return nil return nil
} }
payload, err := io.ReadAll(c.deps.In) payload, err := io.ReadAll(c.deps.In)

View File

@ -144,6 +144,24 @@ func TestHooks_OpenCodeUserPromptReportsActive(t *testing.T) {
} }
} }
func TestHooks_RejectsMalformedSessionID(t *testing.T) {
t.Setenv("AO_SESSION_ID", "../etc/passwd")
cfg := setConfigEnv(t)
srv, capture := activityServer(t, http.StatusOK, `{}`)
writeRunFileFor(t, cfg, srv)
_, _, err := executeCLI(t, Deps{
In: strings.NewReader(`{"reason":"logout"}`),
ProcessAlive: func(int) bool { return true },
}, "hooks", "claude-code", "session-end")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capture.hits != 0 {
t.Errorf("expected no daemon call for an out-of-alphabet session id, got %d", capture.hits)
}
}
func TestHooks_NoSessionIDIsNoOp(t *testing.T) { func TestHooks_NoSessionIDIsNoOp(t *testing.T) {
t.Setenv("AO_SESSION_ID", "") t.Setenv("AO_SESSION_ID", "")
cfg := setConfigEnv(t) cfg := setConfigEnv(t)

View File

@ -6,6 +6,7 @@ import (
"net/url" "net/url"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij"
) )
@ -113,8 +114,16 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
}, },
} }
f := cmd.Flags() f := cmd.Flags()
// --agent is an alias for --harness so the more intuitive `ao spawn --agent
// droid` works identically; both resolve to the same harness flag.
f.SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
if name == "agent" {
name = "harness"
}
return pflag.NormalizedName(name)
})
f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)") f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)")
f.StringVar(&opts.harness, "harness", "", "Agent harness: claude-code, codex, … (default: the daemon's AO_AGENT)") f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: the daemon's AO_AGENT)")
f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>)") f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>)")
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent") f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent")
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session") f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")

View File

@ -7,9 +7,7 @@ import (
"path/filepath" "path/filepath"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree"
"github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/domain"
@ -129,14 +127,10 @@ func newSessionMessenger(store *sqlite.Store, runtime runtimeMessageSender, _ *s
// buildAgentRegistry returns a registry populated with the agent adapters the // buildAgentRegistry returns a registry populated with the agent adapters the
// daemon ships, keyed by manifest id. Registration only fails on an // daemon ships, keyed by manifest id. Registration only fails on an
// empty/duplicate id — a programmer error, not a runtime condition. // empty/duplicate id — a programmer error, not a runtime condition.
// The shipped adapter list lives in the adapters/agent/registry package
// (registry.Constructors). Adding a new harness is a one-line edit there.
func buildAgentRegistry() (*adapters.Registry, error) { func buildAgentRegistry() (*adapters.Registry, error) {
reg := adapters.NewRegistry() return agentregistry.Build()
for _, a := range []adapters.Adapter{claudecode.New(), codex.New(), opencode.New()} {
if err := reg.Register(a); err != nil {
return nil, fmt.Errorf("register agent adapter %q: %w", a.Manifest().ID, err)
}
}
return reg, nil
} }
// agentRegistry adapts the generic adapter Registry to ports.AgentResolver: it // agentRegistry adapts the generic adapter Registry to ports.AgentResolver: it

View File

@ -2,7 +2,8 @@ package domain
import "time" import "time"
// ActivityState is how busy the agent is, derived from its output/JSONL. // ActivityState is how busy the agent is, reported via the agent's CLI hook
// callbacks (see docs/agent/README.md), not inferred from transcript/JSONL
type ActivityState string type ActivityState string
// Activity states. WaitingInput is sticky (see IsSticky). // Activity states. WaitingInput is sticky (see IsSticky).

View File

@ -9,4 +9,23 @@ const (
HarnessCodex AgentHarness = "codex" HarnessCodex AgentHarness = "codex"
HarnessAider AgentHarness = "aider" HarnessAider AgentHarness = "aider"
HarnessOpenCode AgentHarness = "opencode" HarnessOpenCode AgentHarness = "opencode"
HarnessGrok AgentHarness = "grok"
HarnessDroid AgentHarness = "droid"
HarnessAmp AgentHarness = "amp"
HarnessAgy AgentHarness = "agy"
HarnessCrush AgentHarness = "crush"
HarnessCursor AgentHarness = "cursor"
HarnessQwen AgentHarness = "qwen"
HarnessCopilot AgentHarness = "copilot"
HarnessGoose AgentHarness = "goose"
HarnessAuggie AgentHarness = "auggie"
HarnessContinue AgentHarness = "continue"
HarnessDevin AgentHarness = "devin"
HarnessCline AgentHarness = "cline"
HarnessKimi AgentHarness = "kimi"
HarnessKiro AgentHarness = "kiro"
HarnessKilocode AgentHarness = "kilocode"
HarnessVibe AgentHarness = "vibe"
HarnessPi AgentHarness = "pi"
HarnessAutohand AgentHarness = "autohand"
) )

View File

@ -1332,6 +1332,25 @@ components:
- codex - codex
- aider - aider
- opencode - opencode
- grok
- droid
- amp
- agy
- crush
- cursor
- qwen
- copilot
- goose
- auggie
- continue
- devin
- cline
- kimi
- kiro
- kilocode
- vibe
- pi
- autohand
type: string type: string
issueId: issueId:
type: string type: string

View File

@ -121,7 +121,7 @@ type SpawnSessionRequest struct {
ProjectID domain.ProjectID `json:"projectId"` ProjectID domain.ProjectID `json:"projectId"`
IssueID domain.IssueID `json:"issueId,omitempty"` IssueID domain.IssueID `json:"issueId,omitempty"`
Kind domain.SessionKind `json:"kind,omitempty" enum:"worker,orchestrator"` Kind domain.SessionKind `json:"kind,omitempty" enum:"worker,orchestrator"`
Harness domain.AgentHarness `json:"harness,omitempty" enum:"claude-code,codex,aider,opencode"` Harness domain.AgentHarness `json:"harness,omitempty" enum:"claude-code,codex,aider,opencode,grok,droid,amp,agy,crush,cursor,qwen,copilot,goose,auggie,continue,devin,cline,kimi,kiro,kilocode,vibe,pi,autohand"`
Branch string `json:"branch,omitempty"` Branch string `json:"branch,omitempty"`
Prompt string `json:"prompt,omitempty" maxLength:"4096"` Prompt string `json:"prompt,omitempty" maxLength:"4096"`
AgentRules string `json:"agentRules,omitempty"` AgentRules string `json:"agentRules,omitempty"`

View File

@ -37,7 +37,11 @@ type SessionService interface {
ClaimPR(ctx context.Context, id domain.SessionID, ref string, opts sessionsvc.ClaimPROptions) (sessionsvc.ClaimPRResult, error) ClaimPR(ctx context.Context, id domain.SessionID, ref string, opts sessionsvc.ClaimPROptions) (sessionsvc.ClaimPRResult, error)
} }
// ActivityRecorder applies an agent activity-state signal to a session. // ActivityRecorder applies an agent activity-state signal to a session. It is
// satisfied directly by *lifecycle.Manager: an activity signal is a pure
// lifecycle reduction (no runtime/workspace teardown), so it bypasses
// SessionService rather than threading a no-op passthrough through the session
// manager.
type ActivityRecorder interface { type ActivityRecorder interface {
ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ports.ActivitySignal) error ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ports.ActivitySignal) error
} }
@ -253,6 +257,10 @@ func (c *SessionsController) send(w http.ResponseWriter, r *http.Request) {
envelope.WriteJSON(w, http.StatusOK, SendSessionMessageResponse{OK: true, SessionID: sessionID(r), Message: message}) envelope.WriteJSON(w, http.StatusOK, SendSessionMessageResponse{OK: true, SessionID: sessionID(r), Message: message})
} }
// activity records an agent activity-state signal reported by an agent hook
// (via `ao hooks <agent> <event>`). It funnels through the single
// lifecycle.Manager so the reaper and hooks never race on the session's
// activity/termination columns.
func (c *SessionsController) activity(w http.ResponseWriter, r *http.Request) { func (c *SessionsController) activity(w http.ResponseWriter, r *http.Request) {
if c.Activity == nil { if c.Activity == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/activity") apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/activity")

View File

@ -49,6 +49,15 @@ type AgentResolver interface {
// either side hard-coding the other's vocabulary. // either side hard-coding the other's vocabulary.
const MetadataKeyAgentSessionID = "agentSessionId" const MetadataKeyAgentSessionID = "agentSessionId"
// MetadataKeyTitle and MetadataKeySummary are the SessionRef.Metadata keys
// carrying a session's human title and one-line summary. They are the shared
// vocabulary every adapter reports under, so the dashboard renders agents
// uniformly.
const (
MetadataKeyTitle = "title"
MetadataKeySummary = "summary"
)
// AgentConfig holds values loaded from the selected agent's config section. // AgentConfig holds values loaded from the selected agent's config section.
// Agent adapters own validation for their custom keys. // Agent adapters own validation for their custom keys.
type AgentConfig map[string]any type AgentConfig map[string]any

View File

@ -0,0 +1,68 @@
package sqlite
import (
"database/sql"
"path/filepath"
"strings"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// TestMigrateAllowsEveryShippedHarness guards against the collapsed-migration
// silent-no-op concern: a hand-written replace() that fails to widen the
// sessions.harness CHECK (because the target substring drifted) leaves the
// schema accepting only the original harnesses while migrate() still reports
// success. This test opens a fresh DB, runs the migrations, and asserts the
// live sessions schema admits every harness the domain ships, building the
// expected set from the domain constants so it can't silently drift.
func TestMigrateAllowsEveryShippedHarness(t *testing.T) {
db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "ao.db")+pragmas)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
var schema string
if err := db.QueryRow(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='sessions'",
).Scan(&schema); err != nil {
t.Fatalf("read sessions schema: %v", err)
}
harnesses := []domain.AgentHarness{
domain.HarnessClaudeCode,
domain.HarnessCodex,
domain.HarnessAider,
domain.HarnessOpenCode,
domain.HarnessGrok,
domain.HarnessDroid,
domain.HarnessAmp,
domain.HarnessAgy,
domain.HarnessCrush,
domain.HarnessCursor,
domain.HarnessQwen,
domain.HarnessCopilot,
domain.HarnessGoose,
domain.HarnessAuggie,
domain.HarnessContinue,
domain.HarnessDevin,
domain.HarnessCline,
domain.HarnessKimi,
domain.HarnessKiro,
domain.HarnessKilocode,
domain.HarnessVibe,
domain.HarnessPi,
domain.HarnessAutohand,
}
for _, h := range harnesses {
if !strings.Contains(schema, "'"+string(h)+"'") {
t.Errorf("sessions.harness CHECK is missing harness %q — the migration that widens it silently no-opped; schema:\n%s", h, schema)
}
}
}

View File

@ -0,0 +1,42 @@
-- Widen the sessions.harness CHECK to allow every agent harness AO ships, in a
-- single step. SQLite cannot ALTER a CHECK, so we surgically rewrite the stored
-- CREATE TABLE text in sqlite_master. writable_schema edits must run outside a
-- transaction, and RESET forces an immediate schema reparse on the connection.
--
-- New harnesses are added here by extending this list, not by chaining a fresh
-- per-harness migration onto the previous one's exact text.
-- +goose NO TRANSACTION
-- +goose Up
-- +goose StatementBegin
PRAGMA writable_schema = ON;
-- +goose StatementEnd
-- +goose StatementBegin
UPDATE sqlite_master
SET sql = replace(
sql,
'CHECK (harness IN ('''', ''claude-code'', ''codex'', ''aider'', ''opencode''))',
'CHECK (harness IN ('''', ''claude-code'', ''codex'', ''aider'', ''opencode'', ''grok'', ''droid'', ''amp'', ''agy'', ''crush'', ''cursor'', ''qwen'', ''copilot'', ''goose'', ''auggie'', ''continue'', ''devin'', ''cline'', ''kimi'', ''kiro'', ''kilocode'', ''vibe'', ''pi'', ''autohand''))'
)
WHERE type = 'table' AND name = 'sessions';
-- +goose StatementEnd
-- +goose StatementBegin
PRAGMA writable_schema = RESET;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
PRAGMA writable_schema = ON;
-- +goose StatementEnd
-- +goose StatementBegin
UPDATE sqlite_master
SET sql = replace(
sql,
'CHECK (harness IN ('''', ''claude-code'', ''codex'', ''aider'', ''opencode'', ''grok'', ''droid'', ''amp'', ''agy'', ''crush'', ''cursor'', ''qwen'', ''copilot'', ''goose'', ''auggie'', ''continue'', ''devin'', ''cline'', ''kimi'', ''kiro'', ''kilocode'', ''vibe'', ''pi'', ''autohand''))',
'CHECK (harness IN ('''', ''claude-code'', ''codex'', ''aider'', ''opencode''))'
)
WHERE type = 'table' AND name = 'sessions';
-- +goose StatementEnd
-- +goose StatementBegin
PRAGMA writable_schema = RESET;
-- +goose StatementEnd

View File

@ -472,7 +472,7 @@ export interface components {
agentRules?: string; agentRules?: string;
branch?: string; branch?: string;
/** @enum {string} */ /** @enum {string} */
harness?: "claude-code" | "codex" | "aider" | "opencode"; harness?: "claude-code" | "codex" | "aider" | "opencode" | "grok" | "droid" | "amp" | "agy" | "crush" | "cursor" | "qwen" | "copilot" | "goose" | "auggie" | "continue" | "devin" | "cline" | "kimi" | "kiro" | "kilocode" | "vibe" | "pi" | "autohand";
issueId?: string; issueId?: string;
/** @enum {string} */ /** @enum {string} */
kind?: "worker" | "orchestrator"; kind?: "worker" | "orchestrator";