* ci: run the frontend vitest suite on pull requests
The renderer suite was dead for months with nothing noticing — no workflow
executed it (and until #171 it could not even run: vitest auto-loads only
vite.config.ts / vitest.config.ts, which the repo lacked). This job keeps
the revived suite alive.
Typecheck is intentionally left out until the pre-existing forge.config /
update-electron-app type errors are fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(frontend): regenerate package-lock.json in sync with package.json
The committed lock was missing dozens of entries (ansi-regex, error-ex,
minimatch, ...), so `npm ci` under CI's npm 10 hard-fails with "Missing:
<pkg> from lock file" — the new Frontend workflow caught it on its first
run. Newer local npm versions tolerated the drift, which is why it went
unnoticed. Regenerated with npm install (npm 10.9.8, lockfileVersion 3) and
validated with a clean `npm ci` + full vitest run (9/9 files, 99/99 tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(spawn): stop sending branch on spawn, render API errors, wire worker name
Three spawn-modal bugs, re-landed from the closed redesign branch (#156):
- createTask no longer sends `branch`: the API field names the session's
NEW worktree branch, so submitting the modal's default ("main") made the
daemon 409 with BRANCH_CHECKED_OUT_ELSEWHERE on every spawn. The "Based on"
pane is informational — workers branch off the project's default branch in
a fresh worktree.
- API errors render their envelope message instead of "[object Object]":
openapi-fetch resolves non-2xx responses to a plain {code,error,message}
object, not an Error; new apiErrorMessage unwraps it (message + code).
- The "Worker name" field is actually used: after spawn, a best-effort
PATCH rename sets the displayName (a failed rename must not look like a
failed spawn — the worker is already running).
Also revives the renderer test suite, which made these tests (and 7 of 9
suite files) impossible to run on main:
- vitest.config.ts re-exports vite.renderer.config so `vitest run` actually
loads the jsdom environment + setup file. Forge's per-target
vite.*.config.ts names are invisible to vitest, so the existing `test`
block was dead config and every DOM-touching test died on
"window is not defined".
- vite.renderer.config.ts imports defineConfig from vitest/config so its
`test` key typechecks.
- routeTree.gen.ts + the session route are regenerated by the pinned
@tanstack/router-plugin (it runs as part of loading the renderer config;
the checked-in tree predated the installed plugin version and its route-ID
drift caused 3 of main's 7 typecheck errors).
- App.test.tsx wraps App in TooltipProvider, mirroring routes/__root.tsx.
Frontend: 9/9 test files, 99/99 tests pass (was 2/9 files). Typecheck is
down from 7 errors to 3 — the survivors (forge.config notarize/maker types,
update-electron-app call signature) predate this branch and are untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: format with prettier [skip ci]
* fix(gitworktree): base new session branches on the local default branch when no remote exists
Re-lands the remoteless fallback from the closed redesign branch (archived
in 641b712). Creating a session worktree resolved the base for a NEW branch
only via the remote-tracking ref (origin/<defaultBranch>), so a registered
repo with no remote failed every spawn with BRANCH_NOT_FETCHED — an error
that misleadingly names the new session branch and suggests `git fetch`,
which is impossible without a remote.
refs/heads/<defaultBranch> now follows origin/<defaultBranch> in the
candidate list: remote-tracking still wins whenever it exists, and a
remoteless repo bases session branches on its local default branch.
Verified live: a plain `git init` repo (no remote) that previously failed
now spawns, and the integration suite covers it
(TestWorkspaceIntegrationCreateInRemotelessRepo).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(sessions): stop AO hook files from making every worktree permanently dirty
Agent adapters write hook files (.codex/hooks.json, .opencode/plugins/
ao-activity.ts, .claude/settings.local.json, ...) into fresh session
worktrees as untracked files. `git worktree remove` (deliberately run
without --force) refuses on any untracked file, so Workspace.Destroy
failed for every session of the 12 workspace-writing harnesses:
POST /sessions/{id}/kill returned an unlogged 500 INTERNAL_ERROR and
`ao session cleanup` reported 'Would clean N' then '0 sessions cleaned'
with no reason, leaking workspaces forever.
Three coordinated fixes, none of which force-deletes user/agent work:
- Root cause: every adapter now writes a sentinel-guarded, self-ignoring
.gitignore next to its hook files (hookutil.EnsureWorkspaceGitignore),
so AO's own files no longer count as dirt while anything an agent
drops — even in the same directory — still blocks teardown. A
registry-wide conformance test enforces the contract for all current
and future adapters. (Per-worktree .git/worktrees/<name>/info/exclude
was evaluated first but git does not honor it.)
- Typed refusal: gitworktree.Destroy classifies a still-dirty refusal as
ports.ErrWorkspaceDirty (git status probe). Kill maps it to success
with freed=false (session terminated, worktree preserved); Cleanup
reports it per-session as skipped-with-reason through the API
(CleanupSessionsResponse.skipped), and the CLI prints
'Skipped: <id> (workspace has uncommitted changes)' plus a summary.
- Observability: envelope.WriteError records the raw service error into
a request-scoped slot and the access log attaches it to 5xx lines, so
any remaining internal error is diagnosable server-side.
Worktrees created before this fix gain the .gitignore on restore (hook
install re-runs); their cleanup is otherwise reported as skipped instead
of erroring.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cleanup): address Greptile P2s — surface dirty-probe failures, stop leaking raw errors
Two review findings on this PR:
- gitworktree.Destroy: when the isDirty probe itself failed, the error was
silently discarded and the refusal looked identical to "registered but not
dirty". The probe failure now rides the returned error (dirty probe: ...),
so it reaches the access log via the 5xx error capture.
- Cleanup skip reasons: a non-dirty teardown failure put the raw error —
including internal filesystem paths — into the public skipped[].reason
field. The public reason is now the fixed string "workspace teardown
failed"; the full cause goes to the daemon log (warn, with sessionID and
path). The dirty-refusal reason is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gitworktree): wrap the dirty-probe error with %w per errorlint
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(codex): deliver activity hooks via -c session flags, trust worktree at launch
Codex (0.136+) never loads hook config from AO's per-session worktrees:
project-local .codex/ layers only load from trusted directories, and for
linked git worktrees codex sources hook declarations from the matching
folder in the root checkout — so the workspace-local .codex/hooks.json AO
wrote was dead config and codex sessions never reported activity.
Deliver the hooks on the launch/resume command instead:
- -c 'hooks.<Event>=[...]' session-flag config for SessionStart,
UserPromptSubmit, PermissionRequest, and Stop; the session-flags layer
is not trust-gated and aggregates with the user's own hooks. The
existing --dangerously-bypass-hook-trust flag lets them run without a
persisted trust hash.
- -c 'projects={"<worktree>"={trust_level="trusted"}}' (inline-table
form; the dotted projects."<path>".trust_level key is corrupted by
codex's naive -c dot-split) so spawns into never-trusted repos don't
hang invisibly on the interactive directory-trust prompt. Both the
literal and symlink-resolved worktree paths are trusted.
- -c notice.hide_rate_limit_model_nudge=true so the "switch to a cheaper
model?" dialog can't hang a headless pane and swallow the spawn prompt.
GetAgentHooks no longer writes workspace files (worktrees stay clean); it
only strips entries older AO versions left in .codex/hooks.json,
preserving user hooks. UninstallHooks/AreHooksInstalled now operate on
those legacy files only.
Verified with a real spawn into a fresh untrusted repo: activity
transitions idle -> active -> idle hands-free, no .codex dir in the
worktree, no hook delivery failures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(sessions): activity-signal watchdog + hook delivery hardening
A codex upgrade broke activity tracking silently: sessions showed a
confident "idle" forever while the agent worked. This bundle makes hook
delivery verifiable end to end and makes any future breakage loud
instead of invisible.
Watchdog (no_signal status):
- sessions.first_signal_at (migration 0010) records the FIRST hook
callback per spawn/restore — raw signal receipt, independent of the
derived activity state. lifecycle.ApplyActivitySignal stamps it (and
writes through same-state repeats until stamped, e.g. Codex
SessionStart reporting idle on an idle-seeded row); MarkSpawned clears
it so every relaunch re-proves its hook pipeline.
- deriveStatus downgrades a live session with no receipt to the new
no_signal display status after a 90s grace, instead of idle.
Terminated/PR-derived statuses still win. The sessions CDC update
trigger now also fires on first-signal receipt so the dashboard
transition is pushed live.
- frontend maps no_signal -> needs_you (a human should look at the pane).
Hook callback hardening (re-landed from the closed redesign PR #156):
- the session manager pins each spawned session's PATH with the daemon
executable's directory first, so the bare `ao` in hook commands
resolves to the daemon that installed them, with a spawn-time warning
when the pin cannot apply.
- `ao hooks` failures append to $AO_DATA_DIR/hooks.log (size-capped);
`ao doctor` gains a hooks-log check that warns on failures from the
last 24h, and an ao-binary identity check.
Codex launch-surface canary:
- `ao doctor` gains codex-launch-flags: it runs probes exported by the
codex adapter (built from the same flag builders as the real spawn
argv) against the installed binary, warning when codex rejects the
hook-trust bypass flag or AO's -c session-flag overrides.
- codex hook callback timeout drops 30s -> 5s so a hung daemon cannot
stall the agent's turn.
Docs: the agent PRD callback section now describes the implemented flow
(derive state, POST /sessions/{id}/activity, hooks.log) instead of the
unbuilt SQLite/metadata merge, and notes that hook-derived metadata
persistence (codex resume) is still not implemented.
Frontend note: main's renderer test suite has 7 pre-existing failing
files and a vite-config typecheck error unrelated to this change;
workspace.test.ts (the only frontend file touched) passes 26/26.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(store): restore TestSessionWorktreesRoundTrip lost in the re-landing port
The branch ported store_test.go wholesale from the closed redesign
branch, whose copy predates #165 — silently dropping the
session-worktrees round-trip test #165 added. Restore main's file and
re-apply only this branch's addition (TestSessionFirstSignalRoundTrip).
No other ported file lost main-side content (audited per-file against
main; the remaining deletions are this branch's intended refactors).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(status): only derive no_signal for harnesses that have a hook pipeline
Review finding: the no_signal downgrade had no harness-capability gate, but
first_signal_at can only ever be stamped by an `ao hooks` callback. Ten
spawnable harnesses (amp, aider, crush, grok, kimi, devin, auggie, continue,
vibe, pi) install no hooks at all, so every live session of theirs would have
flipped from idle to a permanent no_signal -> needs_you after the 90s grace.
The session service now takes a SignalCapable predicate; daemon wiring injects
activitydispatch.SupportsHarness (the deriver registry is the source of truth
for "this harness can signal"). Left nil, the service never claims no_signal.
A new dispatch test pins that every deriver token is a known harness name.
Also from the same review:
- lifecycle/manager.go and the 0010 migration claimed Codex's SessionStart
reports idle as the first signal; both codex and claude-code derivers
deliberately return no signal for session-start, so the comments now cite a
real case (a lost "active" POST followed by a Stop hook landing idle).
- docs/agent/README.md documents the gate and the restore caveat: a restored
session the user never prompts has nothing to signal, so it shows no_signal
after the grace until a receipt-only session-start signal exists.
- 0010 migration uses DROP TRIGGER IF EXISTS per house style.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat: add workspace project registration schema
* fix: satisfy workspace registration lint
* fix: harden workspace registration edge paths
- Reject linked-worktree and bare parents via validateWorkspaceParent before any mutation
- Roll back git init/.gitignore on failure in initWorkspaceParent so retries are clean
- Reject child repos named __root__ (reserved PK in session_worktrees)
- Serialise Service.Add with addMu to eliminate TOCTOU on concurrent same-path calls
- Fix ensureWorkspaceGitignore permission 0o600 -> 0o644
- Improve guardNoGitlinks suggestedFix with actionable git rm --cached guidance
- Remove dead CASE/__root__ ordering from ListWorkspaceRepos SQL (regenerated via sqlc)
- Resolve RepoOriginURL once per code path in Add (workspace vs single-repo)
- Add 7 tests covering the new edge paths
* chore: add prettier config and CI auto-formatter
Adds .prettierrc and .prettierignore (config only, no local enforcement).
Formatting runs in CI via the prettier.yml workflow: on every push to a
non-main branch, Prettier rewrites changed files and commits the result back
using GITHUB_TOKEN. Developers never need to run Prettier locally.
Intentionally excludes husky/lint-staged — local pre-commit hooks are the
wrong layer for a formatter that the whole team doesn't need installed.
Also adds .envrc.local to .gitignore for personal local shell overrides.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 27336650d2ee
* chore: format with prettier [skip ci]
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* feat(config): persist per-project agent config and resolve it at spawn
Each project can now carry its own agent config (model, permissions,
adapter-specific keys) that survives daemon restart and is resolved into
the launch command when a session spawns.
- storage: add nullable projects.agent_config JSON column (migration 0008);
marshal/unmarshal in the store so the domain carries map[string]any
- resolution: session manager loads the project row and populates
LaunchConfig.Config before GetLaunchCommand
- validation: claude-code declares a ConfigSpec (model, permissions) and
rejects unknown keys / bad types / bad enums at spawn; it applies the
model override and config-driven permission mode (explicit Permissions
still wins)
- surface: PUT /projects/{id}/agent-config + `ao project set-config`
(--set/--config-json/--clear), config shown in `ao project get`
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(claudecode): validate string-list/required config keys and unhandled types
Address review on per-project agent config validation:
- handle ConfigFieldStringList (list of strings) explicitly
- reject unhandled ConfigFieldType via a default case rather than
silently passing
- enforce Required fields are present
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(config): make per-project agent config a typed struct
Replace the free-form map[string]any agent config with a typed
domain.AgentConfig{Model, Permissions} so values are validated when set
(CLI/API) instead of silently dropped at spawn, and the OpenAPI/TS schema
and UI get real typed fields.
- domain: AgentConfig struct + Validate(); PermissionMode moves to domain
and ports re-exports it as a type alias (zero adapter churn)
- storage: marshal/unmarshal the typed struct (IsZero → SQL NULL)
- service: validate on Add and SetAgentConfig; read-model exposes a typed
*AgentConfig
- claudecode: read typed cfg.Config.Model/.Permissions; drop the
map/spec-based validateConfig in favor of the typed Validate()
- cli: typed `ao project set-config --model/--permission/--clear`
- docs: add docs/design/per-project-config.md blueprint sequencing the
remaining # Projects fields toward fully typed per-project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(config): full typed per-project ProjectConfig (store, resolve, surface)
Expand per-project config from agentConfig-only to the full legacy
`projects.<id>` surface, modeled as one typed domain.ProjectConfig
persisted in a single projects.config JSON column.
Wired end-to-end at spawn:
- defaultBranch → base branch for the session worktree (ports.WorkspaceConfig.BaseBranch)
- env → merged into the runtime env (AO-internal vars still win)
- symlinks → repo files linked into the workspace
- postCreate → commands run in the workspace (OS-agnostic shell)
- agentRules / agentRulesFile / orchestratorRules → merged into the prompt
- worker/orchestrator role overrides → harness + agent-config resolution
Stored + validated + surfaced now, consumption deferred (no consumer yet):
tracker, scm(+webhook), opencodeIssueSessionStrategy; sessionPrefix feeds
the display prefix only (session-id generation unchanged).
Validation lives on domain.ProjectConfig.Validate() and runs when config is
set (CLI/API). PermissionMode/AgentConfig stay typed; harness names validated
via domain.AgentHarness.IsKnown().
Surface: PUT /projects/{id}/config (replaces /agent-config) + typed
`ao project set-config` flags (--default-branch/--env/--symlink/--post-create/
--agent-rules/--worker-agent/… or --config-json). OpenAPI + TS regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(lint): tighten symlink dir perms to 0o750 (gosec G301)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(config): centralize default project config + tests
Add domain.DefaultProjectConfig / ProjectConfig.WithDefaults with a single
DefaultBranchName ("main") source of truth, replacing the literal "main"
scattered in the read-model and the gitworktree adapter. Unconfigured
projects now resolve the default branch through one path; every other field
defaults to its zero value.
Tests: defaults present for all fields (DefaultProjectConfig/WithDefaults),
and an unconfigured project reports the default branch + derived session
prefix while omitting the empty config object.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(config): encode documented defaults (branch=main, tracker=github)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(config): fail-safe paths for missing/corrupt per-project config
Address review on default-config / fail-safe spawning:
- projectRules: a missing AgentRulesFile is optional context, skipped
rather than aborting every spawn (only a real read error surfaces)
- store: a corrupt config JSON column degrades to a zero config instead
of failing GetProject/ListProjects/FindProjectByPath for that row
- restore: re-apply the project's resolved AgentConfig so a configured
model/permissions carry across a restore (matches fresh spawn)
Tests: missing rules file skips, corrupt config degrades to zero, restore
applies the project agent config.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(config): trim per-project config to consumer-backed fields
Drop config that has no live consumer yet, so this PR lands only the
fields actually read at spawn/display:
- Remove prompt rules (agentRules, agentRulesFile, orchestratorRules)
from ProjectConfig. Project/agent instructions belong on the system
prompt path or repo-local AGENTS.md, not another rules family.
- Remove future-only integration config with no consumer: tracker, scm,
scm.webhook, and opencodeIssueSessionStrategy (plus their types,
constants, the github tracker default, CLI flags, and spec schemas).
These return in focused PRs alongside the code that reads them.
Kept: defaultBranch, sessionPrefix, env, symlinks, postCreate,
agentConfig (model/permissions), and worker/orchestrator role
overrides. Cross-agent model/permissions support stays follow-up (#157).
Regenerated openapi.yaml + frontend schema.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(config): reject unknown config JSON keys; confine symlink paths
Two review hardenings on the now-trimmed per-project config surface:
- Project add/set-config endpoints decode with DisallowUnknownFields, so
a misspelled or removed config field surfaces as a clear 400 instead
of being silently dropped. Locks the removals from e213b68 (and any
future trims) at the API gate. Covered by new controllers test.
- applySymlinks now refuses absolute paths and any ".." segment via a
safeRelPath guard, so a project config cannot escape the project or
workspace tree via a malicious symlinks entry. Covered by new
session_manager test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(config): reject symlink path traversal at config write time
greptile flagged ProjectConfig.Symlinks as a write-time path-traversal
gap on PR #154 — the runtime guard in applySymlinks catches a malicious
entry on every spawn, but the config itself accepted it. Move the check
into ProjectConfig.Validate so a bad symlinks entry surfaces as
INVALID_PROJECT_CONFIG when set (CLI/API) instead of silently sitting in
the row until the next spawn. The runtime guard stays as
defense-in-depth.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: harshitsinghbhandari <24b4506@iitb.ac.in>
* refactor(observe): extract shared observer skeleton
Move the observer-pattern-general pieces of the SCM observer into a new
backend/internal/observe package so the tracker observer (issue #35) can
build on the same primitives:
- StartPollLoop: goroutine supervisor with immediate-first-poll + ticker
+ ctx-done exit. SCM Observer.Start now delegates to it.
- CheckCredentialsOnce: lazy first-poll credential gate driven by a
CredentialProbe closure. SCM observer keeps credentialsChecked/disabled
as Observer fields; the shared helper mutates them via pointer so
state ownership stays single-source.
- CacheSet[V any] / CacheDelete[V any]: one generic bounded-FIFO helper
replaces the three near-identical cacheSet{String,Time,Bool} bodies
and the standalone evictStrings. The SCM-side methods are now
one-line wrappers that thread o.Cache.max into the shared helper, so
existing call sites and tests are untouched.
SCM behavior is unchanged. The full 21-test SCM suite (including the
end-to-end test added in PR #115) plus 577 backend tests stay green
under `go test -race`.
Part of #112.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(tracker): ports.TrackerObservation DTO + ApplyTrackerFacts reducer
Land the contract that the future Tracker observer (issue #35) and its
provider adapters must satisfy. No observer is wired in this PR — the
DTO + reducer are the deliverable, and locking the shape now lets the
observer + adapter work happen in small follow-up PRs.
DTO (backend/internal/ports/tracker_observations.go):
- TrackerObservation mirrors ports.SCMObservation: Fetched bool,
ObservedAt time.Time, Provider/Host/Repo, normalized Issue facts,
Comments, and a Changed{State, Assignee, Comments} discriminator.
- TrackerIssueObservation carries the minimal facts lifecycle needs
today (state, assignee, title, body, timestamps); richer
per-provider metadata stays inside each adapter.
- TrackerCommentObservation carries the comment fields needed for the
bot-mention nudge (Author, Body, IsBot, ID for dedup).
Reducer (backend/internal/lifecycle/reactions.go):
- ApplyTrackerFacts(ctx, sessionID, ports.TrackerObservation) error,
mirroring ApplySCMObservation's "Fetched gate → terminal-state →
per-bucket reactions" shape.
- Three initial reactions:
* Issue state == done | cancelled → MarkTerminated (idempotent).
* Changed.Assignee → log only via slog.Default(). The "assignee
changed away from AO" policy is reserved for #40.
* Changed.Comments with bot comments → one-time nudge with
strings.Join'd bot bodies, deduped by comment IDs.
- The nudge path reuses sendOnce with an empty prURL so the in-memory
dedup applies but the PR-row persistence path is skipped. Tracker
signature persistence will land with #35 alongside issue-row storage.
Tests in backend/internal/lifecycle/manager_test.go cover each branch:
terminate (done + cancelled), log-only assignee, nudge fires on new
bot comment, nudge suppressed on repeat, new bot comment id refires,
not-fetched is no-op, terminated session ignores observations.
Part of #112.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(observe): rename CacheSet param to avoid shadowing built-in max
golangci-lint revive flagged the CacheSet generic helper's max
parameter as shadowing the built-in max() function. Rename to
maxEntries; signature change is internal to the observe package and
the SCM observer's one-line wrappers pass the value positionally, so
no call sites need updating.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: honour disabled state in CheckCredentialsOnce + tighten bot-comment filter
Two P1 review findings on #116:
1. observe.CheckCredentialsOnce was returning (true, nil) on every
call after the gate ran, even when the probe had marked the
observer disabled, because the *checked short-circuit ignored
*disabled. The SCM observer didn't surface this in practice — its
Poll method has an independent `if o.disabled { return nil }`
guard that runs first — but a future Tracker observer that relies
on the helper's documented contract ("Observer stays disabled")
would silently flip back to "credentials available" after the
first poll. Change the short-circuit to `return !*disabled, nil`
and lock the behavior with a regression test that issues repeat
calls after the probe reported unavailable.
2. lifecycle.newBotCommentContent's "skip uninteresting comments"
filter used && where it needed ||. A bot comment with an empty ID
but a non-empty body slipped through and appended "" to the ids
slice. If every bot comment in the observation had an empty ID,
strings.Join produced "" — which matches the zero value of the
in-memory dedup map, so sendOnce treated the nudge as
already-sent and silently suppressed it forever. Switch to || so
any comment missing either an ID or a body is dropped, and add a
regression test that an empty-ID bot comment never nudges (and
does not pollute the dedup state for a follow-up comment that has
a real ID).
586 tests pass with -race.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(observe): capture deadline once in poll-error spin-wait
The `TestStartPollLoop_LogsPollErrorWithoutPanic` spin-wait was
computing the loop bound as `time.Now().Before(time.Now().Add(200ms))`
on every iteration, which is permanently true — the loop could only
exit via the `break`. Under a scheduler delay (heavy CI load or
`GOMAXPROCS=1`) where two polls never land in time, the test would
hang until the wall-clock kill rather than failing fast.
Capture the deadline once before the loop, and tighten the assertion
to actually require two polls + done-channel closure within a bounded
window, matching `TestStartPollLoop_FirstPollImmediateThenTicks`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cdc): emit pr_review_thread_resolved on replace polls (#152 bug 5)
writePRRows was DELETE-then-UPSERT on the Replace path, so every poll's
upserts hit the INSERT branch and the AFTER UPDATE trigger that emits
pr_review_thread_resolved never fired in production. Replaces the
blanket delete with a set-diff: upsert observed threads first (so
unchanged thread_ids go through ON CONFLICT DO UPDATE and fire the
UPDATE trigger when resolved flips), then delete orphans whose
thread_id is not in the observed set, all inside the existing tx.
Adds DeletePRReviewThread query (sqlc-generated form hand-edited; no
sqlc binary available locally — sqlc generate from backend/ produces an
identical file).
Tests: TestPRReviewThreadsCDC_EmitsResolvedOnReplacePoll (regression —
fails without fix) and TestPRReviewThreadsReplace_PrunesOrphansWithoutReinserting.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(observe): emit scm-disabled log on startup with no subjects (#152 bug 7)
checkCredentials lived only inside Poll, which short-circuits when
discoverSubjects is empty. On a fresh daemon with no tracked PRs the
documented "scm observer disabled: provider credentials unavailable"
warn was unreachable, leaving users with no signal that the SCM
observer was a no-op.
Calls checkCredentials once in Observer.loop before the first Poll.
The existing credentialsChecked guard preserves once-per-process
semantics; provider construction still uses SkipTokenPreflight so
daemon readiness doesn't block on gh.
Test: TestStart_LogsDisabledWarningWhenNoTokenAndNoSubjects with a
race-safe syncBuffer for capturing slog from the observer goroutine.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(api,spawn): typed errors + project/branch/binary preflight (#152 bugs 1-4,6)
Closes the long tail of opaque-500-and-orphan-row failures that
discussion #149's smoke walk surfaced. The common shape: spawn created
the session row before validating preconditions, and the underlying
errors weren't typed, so toAPIError defaulted to INTERNAL_ERROR.
Bug 1 (orphan row + opaque 500 on unknown projectId):
Service.Spawn / SpawnOrchestrator now call store.GetProject first and
return apierr.NotFound("PROJECT_NOT_FOUND", ...) before manager.Spawn,
eliminating the create-row-then-fail-workspace ordering.
Bug 2 (Restore opaque 500 on half-spawned/terminated session):
Manager.Restore gained the ErrIncompleteHandle guard that Kill has at
manager.go:189-193. toAPIError now maps both restore and kill to the
same SESSION_INCOMPLETE_HANDLE 409 envelope.
Bug 3 (--branch unfetched / checked-out-elsewhere → opaque 500):
gitworktree pre-checks listRecords for branch-in-other-worktree, falls
back to refs/tags on missing local/remote head, and emits two new port
sentinels (ErrWorkspaceBranchCheckedOutElsewhere,
ErrWorkspaceBranchNotFetched) mapped to BRANCH_CHECKED_OUT_ELSEWHERE
(409) and BRANCH_NOT_FETCHED (400).
Bug 4 (orphan terminated row on claim-pr rollback):
Adds Store.DeleteSession gated to seed-state rows only (preserves the
no-resurrection guarantee for live sessions), transactional change_log
cleanup, Manager.RollbackSpawn (delete-then-fallback-to-kill), a new
POST /sessions/{id}/rollback endpoint, and rewires
cli/spawn.rollbackSpawnedSession to use it. The exit-0 sub-symptom was
unreproducible from current source and is left unaddressed.
Bug 6 (agent binary not on PATH → silent idle session):
Drops the "return name, nil" anti-pattern from all 21 agent adapters
and returns the new ports.ErrAgentBinaryNotFound on exec.LookPath miss.
Manager.Spawn gained a validateAgentBinary pre-flight (with injectable
LookPath so tests don't need real binaries on PATH) that aborts before
runtime.Create. Mapped to AGENT_BINARY_NOT_FOUND (400). Integration
tests in internal/integration/ stub LookPath to /usr/bin/true.
Tests cover each bug end-to-end. OpenAPI regenerated for /rollback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: gofmt + regen frontend schema.ts for /rollback
CI fixes for #153:
- gofmt/goimports on kilocode and kiro adapters that the bug 6 audit
left mis-grouped.
- openapi-typescript regen against the new /rollback endpoint added in
the Lane A commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(store): guard change_log delete behind seed probe + regen sqlc (#152, PR #153 review)
Addresses @greptile-apps P1 and P2 review feedback on PR #153.
P1 (CDC events deleted for live sessions in rollback fallback):
DeleteChangeLogForSession ran unconditionally inside the transaction
before DeleteSeedSession's seed-state predicates filtered the session
delete to a no-op. For a live session reaching DeleteSession (the
delete-then-kill fallback path inside RollbackSpawn), the seed delete
returned 0 rows but the session_created/session_updated CDC events
had already been purged. Now probes via a new SessionIsSeed query
first and short-circuits the whole tx — including the change_log
cleanup — when the row is not in seed state.
P2 (regen sqlc): installed sqlc 1.31.1 and ran `sqlc generate` from
backend/, replacing the hand-edited pr_review_threads.sql.go (and
producing minor format-only churn in models.go, pr.sql.go,
sessions.sql.go, changelog.sql.go).
The regen surfaced two issues:
1. GetPR / ListPRsBySession had their return types hand-changed to
gen.PR by the previous PR; sqlc actually emits GetPRRow /
ListPRsBySessionRow when queries enumerate columns. Fixed by
collapsing those two queries to `SELECT * FROM pr` so sqlc returns
gen.PR (which is what the store's prRowFromGen converter expects),
and pr.last_nudge_signature now lands in the result alongside the
existing 37 columns.
2. sqlc 1.31.1's SQLite parser silently strips trailing `?`
placeholders and string literals from DELETE statements (reproduced
with sqlc.arg, IFNULL, rowid subquery, and second-predicate
workarounds — all eaten). DeleteSeedSession and
DeleteChangeLogForSession both tripped it. They are now run as
plain tx.ExecContext calls inside Store.DeleteSession, inside the
same write transaction as SessionIsSeed; both queries are removed
from the queries/ directory and the workaround context is
documented inline in queries/sessions.sql and queries/changelog.sql
to keep future contributors from re-adding them.
Verified: go build ./... clean, go test -race ./... 1097/1097 pass.
* ci(frontend): add react-doctor check for landing site
Adds a CI job that runs react-doctor (https://github.com/millionco/react-doctor)
against the Next.js 15 + React 19 landing site at frontend/src/landing/.
The landing has 29 .tsx components and no existing lint coverage; the only
prior frontend CI was a schema.ts drift check in go.yml.
- New workflow .github/workflows/react-doctor.yml, path-filtered to
frontend/src/landing/** so backend-only PRs don't pay for it.
- doctor script + react-doctor devDep added to the landing package.
- Default --blocking=error: surfaces security, bugs, perf, a11y, and
maintainability findings without failing CI on existing warnings
(current baseline: 0 errors, 58 warnings, ~5s locally).
- Runs with --no-telemetry so CI runners don't ping react.doctor.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ci(frontend): also scope react-doctor push trigger to landing paths
Mirrors the PR path filter on push to main so backend-only merges don't
re-run the landing check. Unlike go.yml/cli-e2e.yml (which trigger on
broad path sets), this workflow only cares about frontend/src/landing/.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ci(frontend): use react-doctor GitHub Action, drop devDep + lockfile churn
Replaces the npm install + npm run doctor approach with the official
millionco/react-doctor@v2 composite action. The action manages its own
Node setup and react-doctor install on the runner, so the landing
package.json and lockfile stay untouched (no transitive-dep bloat from
react-doctor's 479-package tree).
The action also wires up sticky PR summary comments, inline review
comments, and commit statuses out of the box — requires pull-requests
and statuses write perms.
* feat(agents): add droid adapter
Registers the droid harness, stacked on the agent platform. Includes its own activity deriver.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(agents): add amp adapter
Registers the amp harness, stacked on the agent platform.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(agents): add agy adapter
Registers the agy harness, stacked on the agent platform. Includes its own activity deriver.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(agents): add crush, aider, goose, auggie, continue, devin, cline, kiro, kilocode, vibe, pi, autohand adapters
Cherry-pick batch landing the remaining 12 yyovil adapter directories per
Discussion #148 recipe, on top of #145 (grok/cursor/qwen/copilot/kimi) and
the droid/amp/agy commits earlier on this branch. Each adapter is a
self-contained package under backend/internal/adapters/agent/<name>/;
registry.Constructors(), activitydispatch.Derivers (for adapters with
activity.go), and wiring_test.go are unified to register all 23 shipped
adapters in one place. No new migration: 0007_allow_implemented_harnesses
already widens the sessions.harness CHECK to cover every adapter.
* fix(agents/kilocode): return error from json.Marshal of permission config
Previously the marshal error was discarded and the function returned a
prefix carrying an empty KILO_CONFIG_CONTENT. An unrecoverable marshal
failure for the typed map should never happen in practice, but if it ever
did, Kilo would silently launch with default permissions regardless of
the requested mode. Surface it as "no prefix" so the caller's mode choice
can't be misrepresented.
---------
Co-authored-by: yyovil <itsyyovil@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(agents): add grok adapter
Registers the grok harness (xAI Grok CLI). grok installs Claude Code-compatible
hooks, so it reuses the claude-code activity deriver already in the platform.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(agents): add cursor adapter
Registers the cursor harness, stacked on the agent platform. Includes its own activity deriver.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(agents): add qwen adapter
Registers the qwen harness, stacked on the agent platform. Includes its own activity deriver.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(agents): add copilot adapter (#128)
* feat(agents): add copilot adapter
Registers the copilot harness, stacked on the agent platform. Includes its own activity deriver.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Update backend/internal/adapters/agent/copilot/hooks.go
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* fix(copilot): map permission-request to documented preToolUse event
Copilot CLI does not document a "permissionRequest" hook event. Per
https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-hooks
the documented camelCase events are sessionStart, sessionEnd,
userPromptSubmitted, preToolUse, postToolUse, errorOccurred, agentStop.
Writing "permissionRequest" into .github/hooks/ao.json silently disables
that hook because Copilot does not recognize the key.
Remap AO's permission-request sub-command onto preToolUse (the closest
documented signal — fires before any tool invocation, including ones
that would prompt for approval) and add a tripwire test asserting the
JSON keys AO writes match the documented camelCase names.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(copilot): gofmt the new tripwire test
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Harshit Singh Bhandari <dev@theharshitsingh.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: harshitsinghbhandari <24b4506@iitb.ac.in>
* feat(agents): add kimi adapter
Registers the kimi harness, stacked on the agent platform.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agents/kimi): drop approval flags on -p and --session paths
Kimi rejects `--prompt` combined with `--yolo`/`--auto`/`--plan`, and
rejects `--yolo`/`--auto` combined with `--session`/`--continue`
(non-interactive and resumed sessions inherit the auto permission
policy). The previous mapping appended one of those flags before `-p`
on every launch and before `--session` on every restore, so every
non-interactive launch would fail at startup. The local binary
(v1.37.0) additionally has no `--auto` option at all, which would
fail even on otherwise-permissible paths.
- GetLaunchCommand: emit approval flags only on the interactive path
(no prompt). The `-p <prompt>` path is now bare.
- GetRestoreCommand: never emit approval flags; resumed sessions
inherit the original session's approval settings.
- Tests assert no approval/plan flag leaks onto either path for any
PermissionMode, and keep the interactive mapping unchanged.
Refs: https://moonshotai.github.io/kimi-code/en/reference/kimi-command.html
* fix(agents/qwen): sync hook settings temp file
* fix(agents/grok): delegate hook cleanup lifecycle
---------
Co-authored-by: yyovil <itsyyovil@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
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>
* feat: add ao hooks activity command
* fix(activity): address review nits
- lcm: sameActivity ignores LastActivityAt so same-state repeats no-op
and don't churn UpdatedAt / CDC events.
- cli/hooks: surface stdin read errors to stderr for parity with the
daemon-error path; still exit 0 so a failed hook can't break the agent.
- claudecode: GetAgentHooks docstring covers Notification + SessionEnd
(the slice already included them; only the comment was stale).
---------
Co-authored-by: harshitsinghbhandari <dev@theharshitsingh.com>
* feat(cdc): add SSE event stream replay
* fix(cdc): document SSE route registration
* test(httpd): cover SSE dedupe and Last-Event-ID cursor paths
Two gaps in events_test.go coverage:
- TestEventsStreamDeduplicatesLiveEventOverlappingReplay: a live event whose
seq falls within the already-replayed range must be silently dropped by
writeSSEEvent so the client sees each seq exactly once. Publishes seq=5
(duplicate of replay) and seq=6 (new) into the live buffer before replay
returns; asserts the client receives [5,6], not [5,5,6].
- TestEventsStreamParsesLastEventIDHeader: Last-Event-ID header must be used
as the replay cursor when the after query param is absent. Source returns
after+1, so receiving seq=8 proves the header was parsed as 7.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: regenerate schema.ts for GET /api/v1/events
Regenerated with npm run api after merging the OpenAPI spec generation
tooling from main. Adds the streamEvents operation and its after cursor
parameter to the TypeScript API types.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: harden SSE event stream headers
---------
Co-authored-by: Vaibhaav <user@example.com>
Co-authored-by: Pritom14 <pritommazumdar1995@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(scm): end-to-end integration coverage for SCM observer (#109)
Adds backend/internal/integration/scm_observer_test.go, the regression
guard for the SCM observer wiring landed in PR #114. Drives
scmobserve.Observer.Poll against a real sqlite.Store, a real
lifecycle.Manager with a recording messenger spy, and a canned
observe/scm.Provider, asserting the full observation -> reducer ->
store -> messenger pipeline.
Three table-driven subtests, each on its own tmpdir fixture:
- A CI-failing observation persists the pr row (provider-neutral
columns + semantic hashes), persists pr_checks mirroring the
observation, delivers exactly one nudge with the failed-log tail,
persists last_nudge_signature, and produces no additional nudge on
an identical re-poll.
- A Merged: true observation MarkTerminated's the session and sends
no nudge.
- A branch with no open PR writes nothing and sends no nudge.
Closes#109
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(scm): address review — drop string key indirection, document idempotency path
- Key cannedSCMProvider.observations/reviews by PR number directly so
the fake no longer carries a string key that resembled (but did not
actually need to mirror) the observer's internal prKey. Every case
in this test uses scmTestRepo, so number alone is unambiguous.
- Add an explicit pointer in the CI-failing subtest noting it
exercises the hash-match short-circuit in prepareForPersistence;
the ETag-driven 304 short-circuit on the same SHA is covered by
observe/scm/observer_test.go (Poll_RepoETag304SkipsDetectPR,
Poll_CIETagChangeRefreshesWhenRepoUnchanged).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(daemon): thread runtime messenger into Lifecycle Manager (#108)
The daemon used to construct the LCM with a nil messenger, so every
SCM-driven nudge dropped silently inside sendOnce. Move newSessionMessenger
above startLifecycle and pass the real messenger through, so CI-failure,
review-feedback, and merge-conflict nudges actually reach the agent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(project): populate RepoOriginURL at add + lazy observer backfill (#108)
project.Add now shells out to `git -C path remote get-url origin` and
captures the result on the new project row, so the SCM observer can parse
it on the first poll. A missing remote falls back to "" rather than failing
project add — non-git roots and remoteless repos stay registerable.
To cover projects added before this change, the observer's discoverSubjects
lazily backfills RepoOriginURL via the same shell-out and persists it
through UpsertProject, so subsequent polls skip the fork-exec.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(lifecycle): persist reaction-dedup signatures across restart (#108)
Add migration 0005 with `pr.last_nudge_signature TEXT NOT NULL DEFAULT ''`
and two scoped sqlc queries (Get/UpdatePRLastNudgeSignature). Lifecycle
serialises the per-PR slice of its seen/attempts maps to that column as a
small JSON document; sendOnce loads it lazily on first touch of each PR
and persists after every successful send.
This closes the post-restart re-nudge gap: the daemon used to lose the
seen map on bounce, so a still-failing CI re-prompted the agent on the
first post-restart observer poll even when it had already been told.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(lifecycle): silence nilerr on intentional corrupt-payload swallow
golangci-lint's nilerr flagged the `if err := json.Unmarshal(...); err != nil { return nil }`
path in loadPRSignaturesLocked. The swallow is deliberate (a corrupt persisted
payload should not crash the lifecycle write path), so compare against nil
directly so no `err` is bound and the lint goes quiet.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: silence nilerr, address reviewer notes, drop task-tagged comments
- reactions.go: discard the json.Unmarshal error explicitly via `_ =` so
golangci-lint's nilerr stops flagging the intentional corrupt-payload
swallow; behavior unchanged.
- reactions.go: document the Send → memory → persist order in sendOnce so
the "one extra nudge on restart after a transient persist failure"
trade-off is explicit (vs. the inverse risk of losing a real nudge).
- service.go: stop reaching for slog.Default() in resolveGitOriginURL;
align with the observer's identical helper that just returns "" on git
failure rather than logging through the global logger.
- tests: drop "issue #108" / "guards the regression from #X" framing in
test docstrings — explain WHAT the test asserts, not the PR context.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat: add npm run api scripts and document API contract change flow
Adds three root package.json scripts mirroring the sqlc convention:
- api:spec — runs go generate to regenerate openapi.yaml
- api:ts — runs openapi-typescript@7.4.4 to generate frontend/src/api/schema.ts
- api — runs both in sequence (the single contributor command)
Documents the full flow in a new AGENTS.md "API contract changes" section
so contributors know which files to edit, how to regenerate, and what to
verify. Implements slice 1 of issue #102.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: correct paths in AGENTS.md API contract changes section
- api:ts equivalent command: use full relative path
backend/internal/httpd/apispec/openapi.yaml, not bare openapi.yaml
- verify command: prefix with `cd backend &&` so it works from repo root
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: add CI drift guard for OpenAPI spec and frontend TS types
- Adds api-drift job to go.yml: regenerates openapi.yaml + schema.ts
via `npm run api`, then fails if git diff detects any change
- Expands go.yml path filter to trigger on package.json and schema.ts
- Commits initial schema.ts generated from the current spec
- Notes the CI guard in AGENTS.md API contract changes section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: address review feedback on api-drift CI job
- Scope git diff to schema.ts only; openapi.yaml drift is already
caught by TestBuild_MatchesEmbedded in the build-test Go job
- Add npm ci step so openapi-typescript is installed from the lockfile
rather than re-fetched by npx on every run
- Move openapi-typescript from npx -y to a pinned devDependency
(exact 7.4.4, no caret) with a committed package-lock.json
- Note in AGENTS.md Verify block that go test does not cover schema.ts drift
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(backend): LLD maintainability fixes in controllers/service layers
Addresses the high + medium severity findings from the LLD review of
backend/internal/httpd and backend/internal/service (#95):
1. Controllers no longer import internal/session_manager. Session sentinel
errors are now *domain.ServiceError values carrying their own HTTP mapping,
so the controller translates them with one generic errors.As — no
cross-package sentinel imports.
2. One error pattern across services: project.Error is now an alias of the
shared domain.ServiceError, and session_manager sentinels use it too. A
single writeServiceError replaces the per-resource error switches.
3. Clean-orchestrator business logic moved out of the controller into
session.Service.SpawnOrchestrator(ctx, projectID, clean).
4. isGitRepo no longer treats case-different paths as equal on case-sensitive
filesystems; case-insensitive compare is gated to darwin/windows via samePath.
5. Project repo check sits behind an injectable GitChecker, so the service is
testable without a real git binary.
6. httpd exports only the production constructors (NewWithDeps,
NewRouterWithControl); the 3 test-only wrappers are removed and the
"router with empty deps" convenience moved to an unexported test helper.
Closes#95
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(backend): standardize service errors on internal/httpd/errors
Replace the domain.ServiceError approach with a REST-API-scoped error package
and a single envelope renderer, per review feedback:
- Add internal/httpd/errors (package errors, aliased apierr): one structured
Error type with semantic Kinds (Internal/Invalid/NotFound/Conflict) and
constructors. Imports nothing, so any layer can depend on it.
- envelope.WriteError is now the single path from a service error to the wire
APIError, and the only place a Kind becomes an HTTP status/word. The
per-resource writeProjectError/writeSessionError translators are gone.
- Delete domain/errors.go (keeps domain pure of HTTP-flavored kinds) and
service/project/errors.go (no per-service error files); services build
errors inline via apierr constructors.
- session_manager sentinels are apierr.Error values (pointer identity still
works with errors.Is).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* revert(backend): drop GitChecker seam and isGitRepo case-sensitivity change
Defer findings #4 (isGitRepo case-sensitivity) and #5 (GitChecker seam) out
of this PR. Restores the original exec-based isGitRepo and the New(store)
constructor; removes git.go, git_test.go, and the test-only export shims. The
error-standardization and other findings are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(session): translate engine errors to API errors at the facade
The session_manager is the internal command engine and must not depend on the
REST API error vocabulary. Revert its sentinels to plain errors.New values and
move the engine→API translation into the service/session facade (toAPIError),
which is the correct boundary. Controllers still see apierr.Error and never
import the engine; the engine no longer imports internal/httpd/errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(session): tighten error comments to state what the code does
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* style(envelope): make KindInternal an explicit case in httpStatus
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(apierr): rename package, test SpawnOrchestrator, parity fixes
Address review feedback on PR #96:
- Rename internal/httpd/errors → internal/httpd/apierr (package apierr) so
importers no longer alias around the stdlib errors package.
- Add a commander seam to session.Service and unit-test the relocated
clean-orchestrator rule: clean=true kills all active orchestrators before
spawning; clean=false spawns without kills.
- project.Add: wrap the UpsertProject store error in apierr.Internal for parity
with its sibling paths (was a raw 500).
- Document that KindInternal is iota's zero value, so a zero-value Error
defaults to 500.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(agent): add opencode adapter + activity plugin hooks
Add an opencode (sst/opencode) agent adapter implementing the 6-method
ports.Agent interface and register it in the daemon's agent resolver, so a
session with harness "opencode" spawns and restores a real opencode worker.
opencode diverges from the claude-code/codex adapters in two ways the adapter
bridges:
- No native command-hook config. Unlike Claude Code (.claude/settings.local.json)
and Codex (.codex/hooks.json), opencode has no "run this command on event"
config (see sst/opencode#5409). Its only lifecycle surface is a JS/TS plugin
loaded from .opencode/plugins/. GetAgentHooks therefore //go:embeds an
AO-owned plugin (assets/ao-activity.ts) and writes it atomically; install is an
idempotent overwrite and uninstall is a sentinel-guarded delete, so
user-authored plugins are never touched. The plugin maps opencode events onto
AO's three normalized activity events: session.created -> session-start,
message.updated/message.part.updated -> user-prompt-submit, and
session.status(idle) -> stop (NOT the deprecated session.idle, which is
unreliable under `opencode run`). It shells `ao hooks opencode <event>` via a
guarded sh -c so a missing `ao` binary is a silent no-op.
- A single approval flag. opencode exposes only --dangerously-skip-permissions
(no graduated accept-edits/auto) and no system-prompt flag, so those map to a
bypass-only permission flag and a documented no-op for the system prompt
(deferred to opencode's own config).
Launch uses the interactive TUI (`opencode --prompt <p>`); restore continues a
captured native session via `opencode --session <id>`.
opencode_test.go mirrors codex_test.go (12 tests) and the daemon wiring test now
asserts the opencode harness resolves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent): address Greptile review on opencode adapter (#80)
- Dispatch all opencode plugin hooks synchronously (Bun.spawnSync). The
session.created handler previously fired session-start via an async
Bun.spawn; if opencode does not await the event handler, a following
message.updated -> user-prompt-submit (sync) could complete before the
in-flight async session-start, so AO would see the prompt before the session
was registered. A sync spawn blocks opencode's single-threaded event loop, so
events are now reported strictly in dispatch order. Removes the now-unused
async callHook helper.
- Fix package/doc comments that said .opencode/plugin/ (singular) to match the
plural .opencode/plugins/ the adapter actually writes to.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent): surface opencode hook failures instead of swallowing them
The activity plugin previously discarded every failure: callHookSync ignored
the subprocess exit code/stderr and both catch blocks were empty, so a failing
`ao hooks` invocation or a malformed event payload was completely invisible.
Now failures are reported through opencode's structured logger (client.app.log)
while still never crashing opencode:
- callHookSync pipes stderr and checks result.success; a non-zero exit (a real
`ao hooks` failure — the `command -v ao` guard makes a missing binary exit 0)
is logged with its exit code and stderr.
- spawn exceptions (e.g. no `sh`) are caught and logged.
- the event-handler catch logs the offending event type instead of swallowing.
- logHookFailure is itself best-effort (optional-chained, rejection swallowed),
so logging can never throw back into opencode.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent): address opencode adapter review — install guard, prompt dedup, hook timeout
Maintainer review on #80 surfaced three pre-merge issues:
- GetAgentHooks could clobber a user file: install overwrote
.opencode/plugins/ao-activity.ts unconditionally while uninstall was
sentinel-guarded. Install now refuses (loud error) to overwrite a file that
isn't AO-managed; absent/AO-managed targets still write idempotently.
- Empty-prompt report poisoned the dedup: message.updated fired
user-prompt-submit with an empty prompt AND marked the message seen, so the
text from the following message.part.updated was deduped away and never
reached AO — breaking title-from-prompt. reportUserPrompt now reports at most
twice: an optional early empty report (keeps run-mode flows active) that does
NOT block a later text report, and a text report that is terminal.
- Bun.spawnSync had no timeout, so a hung `ao hooks` could block opencode
indefinitely. Each spawn is now time-boxed at 30s, matching the claude-code
and codex hook timeouts.
Adds TestGetAgentHooksRefusesToClobberForeignFile and a spawn-timeout assertion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: harden opencode activity hooks
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: harshitsinghbhandari <24b4506@iitb.ac.in>
* feat(api): register PR action route shells (merge + resolve-comments)
Adds two 501 Not Implemented route shells for the SCM/PR action lane
as specified in issue #21. No business logic — the routes are stubs
that return a structured planned body with the embedded OpenAPI spec
slice, consistent with the existing route-shell pattern.
Routes registered:
POST /api/v1/prs/{id}/merge
POST /api/v1/prs/{id}/resolve-comments
Closes part of #18.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(api): PR action routes — full impl (merge + resolve-comments)
Builds the two SCM/PR action routes end-to-end per issue #21:
POST /api/v1/prs/{id}/merge
POST /api/v1/prs/{id}/resolve-comments
**ports/scm.go** — new PRService interface, MergeResult, ResolveResult.
**adapters/scm/github** — adds ErrNotMergeable/ErrUnprocessable sentinels
to the client (405/409/422 classification) and MergePR / ListUnresolvedThreadIDs /
ResolveThread methods to the Provider.
**internal/scm/pr_service.go** — concrete PRService over PRProvider. Parses
the path ID as a PR number, calls the provider, maps github sentinel errors to
domain errors (ErrPRNotFound / ErrPRNotMergeable / ErrPRPreconditions /
ErrNothingToResolve). Nil PRService keeps routes registered but returns
OpenAPI-backed 501s.
**httpd/controllers/prs.go** — real handlers; writePRError maps the four domain
errors to 404 / 409 / 422 / 500.
**prs_test.go** — httptest coverage: 501 (nil service), 200/404/409/422 for
both routes, spec-slice present in 501 body.
**scm/pr_service_test.go** — table-driven unit tests with a fake PRProvider.
Closes part of #18. Closes#21.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(api): PR action routes — merge + resolve-comments (#21)
Implements POST /api/v1/prs/{id}/merge and POST /api/v1/prs/{id}/resolve-comments.
Service logic lives in internal/service/pr (ActionManager interface + ActionService
struct). Controllers use the projects pattern — import the service package directly
rather than going through a ports interface. Drops the internal/scm intermediary
package and the ports/scm.go file added in earlier iterations.
Also fixes the ContentLength-based body-decode guard in resolveComments, which
silently dropped JSON bodies sent with chunked transfer encoding; now decodes
unconditionally and treats io.EOF as an absent body.
Closes#21.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(specgen): remove dead path-param entries from schemaNames
ControllersProjectIDParam, ControllersSessionIDParam, and ControllersPRIDParam
are never matched by the schemaName interceptor — swaggest reflects path-param
structs inline rather than as $ref component schemas, so the hook is never
called for these types.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(pr): anchor resolve-comments to stated PR when explicit IDs supplied
When commentIDs were provided, the parsed PR number was never used — any
thread ID could be resolved regardless of which PR was in the URL path.
Add a ListUnresolvedThreadIDs existence probe in the else branch so the PR
must be reachable before iterating the caller-supplied IDs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(controllers): exclude io.ErrUnexpectedEOF from isEmptyBody
A truncated body (e.g. {"commentIds":["T_1") returns io.ErrUnexpectedEOF,
not io.EOF. Treating it as an absent body caused the handler to fall through
to "resolve all unresolved threads" instead of returning 400. Only io.EOF
(reader returned no bytes) is a genuine empty-body signal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(api): revert to route shell — stubs, no adapter changes
- Remove adapter changes (ErrNotMergeable/ErrUnprocessable from client.go,
MergePR/ListUnresolvedThreadIDs/ResolveThread from provider.go)
- ActionService returns dummy values with TODO; no business logic
- Errors (ErrPRNotFound etc.) moved to controllers/errors.go
- PR DTOs moved to controllers/dto.go
- Remove 501 guards — stub service always wired via NewAPI default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: restore client.go, move PR errors to service/pr, fix lint
- Restore original client.go alignment (no functional change)
- Move PR sentinel errors to service/pr/errors.go
- controllers/errors.go now only contains writePRError, referencing prsvc sentinels
- Fix schemaNames alignment in specgen/build.go (goimports lint)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(api): replace fake-success stub with 503 when SCM not configured
The nil-service fallback was silently injecting a stub that returned
fake merge/resolve success, misleading callers when no SCM is wired.
Remove the injection; nil Svc now returns 503 SCM_NOT_CONFIGURED.
Also inline writePRError into prs.go and delete controllers/errors.go.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(specgen): mark resolve-comments request body as optional
reqBody: nil removes the requestBody.required: true annotation so
generated SDK clients can omit the body (which resolves all threads).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(prs): align nil-service guard with spec (501) and echo prID in stub
Use apispec.NotImplemented (501) instead of 503 so nil-service responses
match the OpenAPI spec and generated clients hit the documented 501 branch.
Echo prID as PRNumber in the stub Merge to avoid claiming the wrong PR
was merged if NewActionService is wired by accident before real impl lands.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(messenger): ao send + live zellij pane ping (live agent nudges)
Replace the daemon's noopMessenger stub with a composite AgentMessenger
that writes a durable inbox file (primary) and types a live pointer into
the running zellij pane (best-effort secondary), plus the `ao send` CLI
that drives the existing POST /api/v1/sessions/{id}/send route.
- composite: fans Send to inbox then panep, pinning one timestamp so both
derive the same filename; a secondary failure is logged at WARN and
swallowed (the file is on disk), a primary failure aborts the call.
- inbox: writes <workspace>/.ao/inbox/<rfc3339nano>_<hash>.md.
- panep: types "new message at .ao/inbox/<file>" + Enter via a new narrow
zellij WriteChars seam (RuntimePaneWriter), kept off ports.Runtime.
- wiring: newSessionMessenger composes inbox+panep over the shared store;
startSession takes the messenger instead of the noop stub.
Carries across @aa-43's work from PR #74 (staging), adapted to main's
post-#65/#77 daemon wiring shape.
Closes#79
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(inbox): use O_EXCL so a filename collision errors instead of clobbering
os.WriteFile opens with O_CREATE|O_WRONLY|O_TRUNC, which silently overwrites
an existing file. The doc comment already stated the intent ("we do not retry
on EEXIST"), but O_TRUNC never yields EEXIST — two identical messages sent on
the same composite-pinned nanosecond would produce the same filename and the
second Send would silently lose the first message. Switch to
O_CREATE|O_EXCL|O_WRONLY so a collision surfaces as an error; O_EXCL also
refuses to follow a symlink at the final path component. Add a regression test.
Addresses greptile review on PR #83.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(inbox): remove the freshly-created file when write or close fails
The O_EXCL switch creates the inbox file before writing its body; if
WriteString or Close then fails, the empty/partial .md was left on disk and
the agent's next inbox scan would pick up a truncated ghost message. Remove
the file on those error paths. O_EXCL guarantees the file did not exist before
this call, so the cleanup can only delete our own partial write, never a
legitimate earlier message.
Addresses greptile review on PR #83.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(messenger): reduce ao send to live pane delivery
* fix(send): preserve messages and map lookup errors
* fix(send): reject terminated sessions