* fix(agentctl): reap disconnected ACP sessions + MCP child trees (#1247)
Two independent leaks let opencode acp sessions accumulate indefinitely:
1. instance.Manager never reaped disconnected sessions. StopInstance
only ran via the kandev DELETE route, so any session whose backend
went away (UI closed mid-run, WS dropped, opencode failed to emit
complete) was orphaned forever.
2. Even when DELETE fired, the ACP adapter unconditionally returned
RequiresProcessKill=false. opencode acp keeps an HTTP server + MCP
child tree alive after stdin EOF, so closing stdin was a no-op and
the children re-parented to init.
Fix both:
- Plumb RuntimeConfig.RequiresProcessKill from agents.OpenCodeACP
through the ExecutorCreateRequest -> CreateInstanceRequest ->
InstanceConfig -> adapter.Config -> shared.Config chain. acp.Adapter
now reads cfg.RequiresProcessKill instead of hardcoding false.
- Belt-and-braces: process.Manager.waitForProcessExit timeout fallback
now killProcessGroup first (only falls back to single-process kill
if that errors), so any future ACP CLI that ignores stdin EOF still
gets reaped.
- Add an idle-instance reaper to instance.Manager. Per-instance
activity middleware tracks inflight HTTP requests + lastActivity;
a background goroutine sweeps every minute and StopInstance's any
instance idle past KANDEV_ACP_IDLE_TIMEOUT (default 1h, set to 0
to disable). Long-lived /agent/stream WS connections keep the
inflight counter above zero so active sessions are never reaped.
- Validate stdio MCP commands at CreateInstance time via
exec.LookPath; missing entries are dropped with a warn instead of
spawning a permanently broken child every session (fixes the
stale /snap/bin/brave MCP repro in the issue).
Regression tests: process-group kill (parent+child fixture), idle
reaper end-to-end, MCP validation, opencode-acp opt-in vs other
ACP agents' default.
* fix: address PR review feedback on #1247 fix
- idle_reaper: extract reapSingleIdleInstance helper with defer cancel(),
add reaperStop polling between sweep iterations so Shutdown drains in
one StopInstance window instead of N * timeout (cubic, claude, greptile).
- instance.Manager: use sync.Once instead of m.mu to gate reaper-stop
channel close (claude).
- mcpStdioValidationError: tolerate compound Command strings by
resolving only the first whitespace-delimited token; reject entries
with neither URL nor Command outright; document agentctl-vs-agent
PATH skew in Docker/SSH executor modes (claude, greptile, cubic).
- manager_killgroup_test: treat zombie state as dead via /proc/<pid>/stat
on Linux + bump Eventually window to 15s — CI containers' PID 1 can
delay harvesting orphans long enough to flake signal(0) probes.
- instance package: add goleak TestMain for the new reaper goroutine
(coderabbit).
- AGENTS.md: correct SIGKILL vs SIGTERM wording; clarify the timeout
fallback only fires on ctx.Done (coderabbit, cubic).
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix(acp): handle claude-acp async_launched subagents end-to-end
Claude Agent SDK's Task tool with run_in_background=true returns
session/prompt with stop_reason=end_turn while subagents stay alive
in the background, reporting `_meta.claudeCode.toolResponse.status =
"async_launched"` instead of a terminal top-level status. kandev
previously treated stop_reason as authoritative, cancelled the
subagent card on prompt return, and fired an "agent finished without
producing any output" notice — even though the dispatch did produce
work and the subagent's child events would arrive seconds later.
Backend:
- `SubagentTaskPayload` gains `IsAsync` / `OutputFile` /
`CanReadOutputFile` so the async-launched envelope is preserved.
- `convertToolCallResultUpdate` recognizes async_launched as terminal
for the Task tool itself (dispatch is the completion).
- `cancelActiveToolCalls` preserves subagent_task tool calls through
the prompt-return sweep so a late terminal tool_call_update from
the SDK still lands correctly (covers the in-turn-complete case).
Frontend:
- `subagentMetaChips` emits a "background" chip when `is_async` or
`status == "async_launched"` so the card is visually distinct.
- `computeEmptyTurnNotice` suppresses the notice when any subagent
task message exists for the turn (the dispatch IS output).
Reproduced via `acpdbg prompt` against `claude-acp` 0.31.1; captured
frames in acp-debug/claude-acp-prompt-20260602-110119.jsonl drive the
backend test fixtures verbatim.
* fix(acp): harden async_launched override against future SDK frames
Address PR review feedback (greptile #1244, claude review):
- Move the async_launched override inside the mutex lock so it can
inspect the stored payload kind before flipping the status.
- Drop the `status == ""` precondition. The earlier in_progress
synthesis (Title/RawInput/Content/Locations) was implicitly trumping
the override; a future SDK frame that combines async_launched with
any of those fields would have left the card stuck in_progress
forever. The override is now unconditional for async_launched
subagent_task frames.
- Gate the override on `payload.Kind() == ToolKindSubagentTask`. A
hypothetical future claude-acp tool that reuses the `async_launched`
literal in its own `_meta.claudeCode.toolResponse.status` would have
been incorrectly marked complete by the prior unscoped check.
Pinned by two new tests:
- TestConvertToolCallResultUpdate_AsyncLaunched_OverridesInProgress
- TestConvertToolCallResultUpdate_AsyncLaunched_NonSubagentNotTerminated
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* chore(agentctl): delete duplicate procattr files in adapter package
setProcGroup and killProcessGroup are defined in both
agentctl/server/adapter/ and agentctl/server/process/. The process/
copies are the live ones (called by process/runner.go, manager.go,
vscode.go); the adapter/ copies have zero callers and are removed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(agentctl): remove orphan helpers from transport/shared
Left over from the ACP refactor. All had zero callers:
- DebugEnabled(): internal debugMode var is checked directly by
LogRawEvent/LogNormalizedEvent — the exported wrapper was unused.
- GetBool, GetMap, GetSlice: only GetString/GetInt are called in acp/normalize.go.
- NormalizeShellResult, NormalizeReadResult, NormalizeCodeSearchResult,
NormalizeModifyResult: normalization moved to acp/normalize.go.
- TruncateIfNeeded, MaxContentLength, MaxFileCount: only used by the
four Normalize* helpers above; removed transitively.
- TraceControlMessage: zero callers in the adapter transport layer.
GenerateUnifiedDiff, SplitLines, DetectLanguage, Tracer, ShutdownTracing,
TraceProtocolEvent, TraceProtocolRequest are alive and kept.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(agentctl): remove unused tool_payload constructors
NewHttpRequest, NewCreateTask, NewSubagentTask, NewShowPlan,
NewManageTodos, and NewMisc have zero callers — ACP normalization
builds NormalizedPayload via struct literals and JSON unmarshaling,
not these factory functions. NewReadFile, NewModifyFile, NewShellExec,
NewCodeSearch, and NewGeneric are kept (live callers in acp/normalize.go).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(agents/detect): remove dead fluent options and OSPaths type
WithFileExists, WithCommandOutput, WithEnvVar, expandHomePath (their
transitive helper), and OSPaths (struct + Resolve + Expanded) have
zero callers. Only WithCommand and WithNpxRunnable are used by agent
IsInstalled implementations. Imports trimmed to context and os/exec.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(office): remove dead channel methods superseded by service/
ListComments, DeleteComment, and UpdateChannelStatus on ChannelService
have zero callers — the live implementations live in
internal/office/service/comments.go and service/channels.go.
CreateComment is kept: it is called by HandleChannelInbound.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: remove scattered orphan helpers
Remove dead single-symbol exports verified to have zero callers:
- ParseCronExpression (office/shared/cron.go): NextCronTime covers
all live usages; the validator wrapper was unused.
- IsValidPathComponent, MustJSON (office/shared/helpers.go): zero
callers; GenerateSlug is kept.
- DiscoverUserSkillsAt (office/skills/userhome_discovery.go): exported
wrapper over the private discoverUserSkillsAt; only the internal
DiscoverUserSkills method is wired up.
- Manager.Get (agent/loginpty/manager.go): zero callers; GetByID and
Stop are kept (Stop is tested in manager_test.go).
- DetachedWithValues (common/appctx/appctx.go): zero callers; Detached
is kept.
- Repository.Close no-ops (task/share, terminal/repository): both are
documented no-ops with zero callers.
- TaskBranchName (worktree/config.go): zero callers; the deterministic
TaskBranchNameWithSuffix is kept.
Note: dialect.Now was initially targeted but is tested in
dialect_test.go (same-package call — missed by package-qualified grep).
It remains unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(share): remove stale Close reference from provider.go comment
Repository.Close() was removed as a no-op in the round-4 cleanup.
The comment in provider.go still referenced it by name; updated to
reflect that the repo simply doesn't own its DB connections.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(office/shared): delete helpers.go — GenerateSlug had zero callers
The triage step mistakenly identified skills/service.go as a caller,
but that package defines its own local GenerateSlug (different regex,
adds TrimSpace and a 'skill' fallback default). shared.GenerateSlug
was never called anywhere. helpers.go is now empty; delete the file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(worktree): fix stale TaskBranchName reference in TaskBranchNameWithSuffix doc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(acp): split adapter.go into per-concern files
Pure file split of the 2,083-line transport/acp/adapter.go into sibling
files inside the same package — no behavior change, no new types, no
signature changes. The struct, constructor, lifecycle, and locking-
sensitive helpers (sendUpdate, trace ctx, Close) stay co-located in
adapter.go (422 lines); session/mcp/auth, prompt/cancel/wakeup, update
dispatch, tool-call conversion, permission flow, and cross-file helpers
each move to their own file, all under the 800-line lint cap.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(acp): restore struct doc wording on availableAuthMethods
independent-review fix: the file split should have been byte-identical for
comments. Restore "surface a picker" (matches origin/main) — accidentally
rewritten as "drive a picker" during the adapter.go rewrite in ccfad56d.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ci(acp): extract toolStatusInProgress / toolStatusCancelled constants
CI golangci-lint flagged 'in_progress' (9 occurrences) and 'cancelled'
(4 occurrences) as goconst violations after the file split surfaced them
as "new" lines. Extend the existing toolStatus* constant set in
normalize.go and use the new constants at every production call site
(adapter_tools.go, adapter_prompt.go, monitor.go). Behavior unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
PR #914 globally renamed Wakeup→Run in the ACP transport, including the
wire-level tool name check (`name != "ScheduleRun"`). That string is
hardcoded by the Claude Code SDK binary as `ScheduleWakeup`, so the
bridge's tool-call frames stopped matching and every Claude wakeup
silently never fired: kandev never armed the timer, never sent the
synthetic session/prompt, and the SDK's buffered wakeup turn only
drained when the user typed a new message.
Commit 0b2c59a2's lifecycle fix (publish AgentReady on wakeup turns)
relied on the wakeup turn actually firing — which it wasn't — so the
fix appeared not to work in production.
Restore the wire string to `"ScheduleWakeup"` and rename the surrounding
Go symbols back to wakeup-naming (`wakeupScheduler`, `pendingWakeup`,
`handleWakeupEvent`, `fireWakeup`, `wakeupPromptTimeout`) so the next
contributor can't repeat the rename. Add a comment at the match site
warning that the string is externally hardcoded, plus a negative
regression test asserting `"ScheduleRun"` is NOT recognised as a wakeup.
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix(lifecycle): publish AgentReady on wakeup-driven turns
Wakeup turns flow Adapter.Prompt → bridge → events, bypassing
SessionManager.SendPrompt. The execution status stayed Ready across
the wakeup turn, so MarkReady's duplicate-suppression guard dropped
the second AgentReady — the orchestrator never closed the turn,
never evaluated on_turn_complete, and never dispatched queued
messages. Detect this in recordActivity and flip Ready → Running on
the first non-terminal event, re-emitting AgentRunning so the rest
of the pipeline behaves like any other turn.
* fix(lifecycle): gate wakeup flip on turn-content events and handle empty turns
Three follow-ups on the wakeup AgentReady fix:
1. Restrict the Ready → Running flip in `recordActivity` to events that
unambiguously indicate turn-in-progress activity (message_chunk,
reasoning, tool_call/update, plan, agent_plan, permission_request).
The original whole-event-type flip caused a regression: claude-agent-acp
emits available_commands_update asynchronously ~50ms after session/new,
well after dispatchInitialPrompt fires MarkBootReady for a no-prompt
task. The Ready → Running flip on that delayed metadata event was
putting newly-created tasks back into Running and breaking the chat-
input UI (changes-panel-focus E2E tests).
2. Handle the empty-wakeup edge case: a wakeup turn that emits only a
`complete` event (empty model response) is still excluded from the
flip whitelist, so the Ready guard in MarkReady would suppress
AgentReady. `handleCompleteEventMarkState` now publishes AgentReady
directly when Status is already Ready on a successful complete —
the orchestrator still closes the turn for empty wakeup responses.
3. Fold the `executionStore != nil` check into the early-return so the
AgentRunning publish only fires when the status flip actually succeeded.
Test-file cleanup from review feedback: dead loop, redundant
`var _ = io.MultiReader`, and `teeReader.src any` → `io.Reader`.
New regression tests:
- TestWakeup_PostBootMetadataEventsDoNotFlipToRunning — locks in the
whitelist behavior so future events can't accidentally re-arm a
boot-ready session.
- TestWakeup_EmptyTurnStillPublishesAgentReady — covers the
empty-wakeup-complete fallback.
* fix(lifecycle): publish AgentRunning before AgentReady on empty wakeup turns
Follow-up on the previous wakeup fixes — addresses three issues raised in
review:
1. **Empty-wakeup AgentReady was ignored by the orchestrator.** The fallback
in `handleCompleteEventMarkState` published AgentReady when Status was
already Ready, but `handleAgentReady` at the orchestrator early-returns
when `session.State != Running/Starting`. After the previous turn ended
the session is in WaitingForInput, so the AgentReady got silently
dropped — same suppression the wakeup fix was meant to prevent. Now the
fallback flips Ready → Running + publishes AgentRunning first (mirroring
`recordActivity` for the non-empty case), then falls through to MarkReady.
2. **firstActivityOnce could still publish AgentRunning on a late
metadata event.** Gate it on `Status != Ready` so a delayed
`available_commands_update` arriving after MarkBootReady (the original
regression scenario) can't slip through via the once-per-execution path.
In practice the adapter always emits `agent_capabilities` during
Initialize before MarkBootReady, so firstActivityOnce still fires there
with Status==Running — this is defensive hardening.
3. **Tighter test assertions.** The post-boot-metadata test now primes
firstActivityOnce in setup and asserts strict `runningCount == 0`.
The second-turn test verifies AgentRunning precedes each AgentReady in
bus order so the orchestrator's session-state guard isn't violated.
Same ordering check added to the empty-wakeup test.
* test(lifecycle): rename exec to agentExec to avoid os/exec shadow
The test variable `exec` in wakeup_e2e_test.go shadowed the imported
`os/exec` package — harmless today because all `exec.Command`/`LookPath`
calls happen before the local binding, but a future call added below
would silently fail. Rename to `agentExec` to remove the shadow.
* ci: retrigger CodeQL after transient Ruby analyze flake
The default-setup CodeQL run on PR #875 produced a flaky 'CodeQL could
not process any code written in Ruby' failure on the Ruby analyze job
(github.com/kdlbs/kandev/actions/runs/26124163769). The repo has no
Ruby code and main's CodeQL only analyzes javascript-typescript +
actions; the Ruby job was added dynamically and failed on the empty
diff. Sibling run 26124163815 on the same SHA passed. Cannot rerun
default-setup workflows via the API (403 'cannot be retried'), so
push an empty commit to retrigger the analysis.
* test(lifecycle): tighten wakeup test assertions
- wakeup_simulated_test.go: assert exact readyCount==2 instead of <2 so
any over-emission (e.g. duplicate AgentReady on the wakeup turn) is
also caught.
- wakeup_e2e_test.go: fail with t.Fatalf when the adapter surfaces no
WAKEUP text instead of logging a warning. Otherwise the bus-level
assertions below pass vacuously if the wakeup never fires.
Both fixes address review comments from cubic-dev-ai / claude[bot] on
PR #875.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(lifecycle): remove unsynchronised SessionID write in forwarder goroutine
The forwarding goroutine wrote to agentExec.SessionID without holding
the executionStore mutex, racing with the main goroutine's authoritative
assignment after NewSession returns. The forwarder's write is redundant
— line 264 sets the canonical value — and benign in practice, but
`go test -race` would flag it. Drop the write to keep the race detector
clean.
Addresses review comment from claude[bot] on PR #875.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(acp): stop inventing currentModelId from AvailableModels[0]
When session/new returned an empty currentModelId, the adapter fell back
to AvailableModels[0]. Auggie returns an alphabetically-sorted list whose
[0] is a pseudo-agent ("Build Analyzer"), so the UI showed the wrong
model on every fresh load and session resume.
Drop that fallback (the configOption-based fallback stays — that one is
legitimate). When neither CurrentModelId nor a model-shaped configOption
provides a value, emit empty and let the frontend resolve to the profile
or snapshot model.
Also emit a session_models convergence event after SetModel succeeds,
so the UI updates after the lifecycle manager applies the profile model
at session init and after user-driven /session/set_model calls. Cache
configOptions alongside availableModels for inclusion in that event.
* fix(acp): tighten SetModel convergence event semantics
Address PR #947 review feedback:
- Bind the convergence event to the session captured under the same
RLock used to read the connection. `SetModel` now snapshots
`sessionID`, `available`, and `cachedConfig` together and passes
them into `emitSetModelEvent`, so a concurrent session switch can
no longer route the event to the wrong target.
- Rewrite the model-shaped configOption's `CurrentValue` to the new
`modelID` before emitting. Without this the event carried
`CurrentModelID = <new>` alongside `ConfigOptions[model].CurrentValue
= <old>` — a latent inconsistency for codex-style consumers that
read the option value. The cached slice is copied first so the
caller's snapshot is not mutated.
- Refresh `availableConfigOptions` under `Lock` from inbound
`ConfigOptionUpdate` notifications, so a subsequent `SetModel`
convergence event uses the latest options instead of the
session/new snapshot.
- Log the convergence event with `session_id` and `model_id`, matching
the logging already on `emitSessionModels`.
- Extract a `configOptionIDModel` constant for the well-known "model"
ID (silences goconst on the new helper).
Tests: extend `TestEmitSetModelEvent_EmitsSessionModelsWithCachedState`
to assert the `CurrentValue` rewrite and the caller-slice
no-mutation guarantee; add `TestConfigOptionUpdate_RefreshesCachedConfig`
to pin the cache refresh.
* test(acp): tighten ConfigOptionUpdate cache-refresh assertions
Address two minor follow-up suggestions from Claude on PR #947:
- Add a comment to emitSetModelEvent noting that the cachedConfig
copy is shallow (Options shares the caller's backing array) — safe
today because only CurrentValue is mutated, but worth flagging in
case a future caller mutates Options in place.
- Have TestConfigOptionUpdate_RefreshesCachedConfig assert that the
returned event's CurrentModelID resolves to the fresh value from
the refreshed configOption, so the test pins both the cache update
and the event payload in one place.
* test(acp): cover Category-based model option rewrite path
Address Claude's follow-up suggestion on PR #947: the previous test
only exercised the ID == "model" branch of emitSetModelEvent's
CurrentValue rewrite, so an accidental drop of the
Category == "model" branch would have passed. Add a third entry
with Category: "model" (and a non-matching ID) and assert both
matched options are rewritten while the unmatched one is untouched.
* fix(agentctl): inject HTTP MCP server for Codex ACP support
Codex ACP only supports HTTP-based MCP servers (not SSE). The kandev
MCP server was only injected with SSE transport, causing it to be
filtered out by Codex ACP's capability check.
Now inject both SSE and HTTP variants of the kandev MCP server:
- SSE at /sse (for Claude Code)
- HTTP at /mcp (for Codex ACP)
The agent's capability filtering automatically selects the appropriate
transport based on what the agent supports.
* refactor(agentctl): address code review feedback
- Add name deduplication to filterMcpServersByCapabilities to prevent
duplicate MCP server entries if an agent supports both transports
- Extract injectKandevMcpServers helper to centralize dual-transport
injection logic in agent.go
- Add constants for MCP server name and transport types
- Add test for duplicate name deduplication
* fix(agentctl): add MCP injection to handleWSResetSession
Apply injectKandevMcpServers to session reset handler, ensuring MCP
tools remain available after context window reset.