Commit Graph

629 Commits

Author SHA1 Message Date
Carlos Florêncio a2b4fc0c6d fix(web): keep sidebar 3-dot menu from overlapping the subtask toggle (#1012) 2026-05-22 15:36:46 +01:00
Carlos Florêncio c9b6fe4b8a fix(worktree): support fork PR worktree creation via pull refspec (#990) 2026-05-22 12:15:39 +01:00
João Salavisa 07a9784d1c fix(share): route share links through gist.githack so big tasks render (#1008)
* fix(share): route share links through gist.githack so big tasks render

gistpreview.github.io fetches gists via the public GitHub API, which returns
`content: ""` for files past GitHub's per-response content budget (~1 MB).
For non-trivial sessions, share.html and snapshot.json land past that
budget and gistpreview's `document.write(content)` renders a blank page.
gist.githack.com proxies the raw file content directly, so multi-MB
share.html renders correctly — at the cost of a one-time Cloudflare
anti-phishing interstitial on first visit, accepted as the price of
fidelity for big tasks.

displayURL now normalises bare gist URLs and unpinned githack URLs to the
canonical `gist.githack.com/<owner>/<id>/raw/share.html` form; legacy
gistpreview URLs pass through unchanged (the owner is not recoverable
from a gistpreview URL, so users can revoke + re-share to get the
githack form going forward).

* fix(share): address PR #1008 review feedback

- displayURL now re-pins legacy gistpreview URLs stored without /share.html
  so they hit the styled view instead of silently falling back to the
  alphabetically-first README.md (flagged by Greptile).
- Spec: unify two stale gistpreview references (API endpoint description
  and the publish scenario) to the canonical githack form, with a note
  that legacy gistpreview rows pass through. Flagged by CodeRabbit and
  Claude — the spec is the public contract; one stale mention undermines
  the migration.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-22 11:40:36 +01:00
Nicolas Lenepveu e740417885 feat(linear): enrich issue watch filters with priority, labels, creator, estimate (#974)
* feat(linear): enrich issue watch filters with priority, labels, creator, estimate

Adds priority, labels (multi-select), creator (team member select), and
estimate range filters to Linear issue watchers — closing the gap with
Linear's GraphQL filter surface so users can scope auto-run watchers
nearly as tightly as they would in Linear itself.

- Backend: extend SearchFilter, map new fields to IssueFilter, add
  ListLabels / ListUsers on the Client interface, expose them over HTTP
  (GET /api/v1/linear/labels, /users) and via SearchIssues query params.
- Frontend: extend LinearSearchFilter, fetch labels + users per team,
  render new filter controls in the watcher dialog, and split helpers
  into linear-issue-watch-form.ts + linear-issue-watch-fields.tsx so
  the dialog stays under file/function size limits.

* fix(linear): address reviewer feedback on enriched filter PR

- Reject invalid priority / malformed estimate query params with 400 instead
  of silently dropping them; reject estimateMin > estimateMax. Mirror the
  bounds check in the issue-watch validator so JSON-encoded watches go
  through the same gate.
- A11y: render label/state toggle Badges inside <button aria-pressed> so
  keyboard users can navigate and screen readers can announce state.
- Recover from per-team fetch failures so a transient network blip doesn't
  permanently mark a team as "fetched-empty" — clear the in-flight marker
  when any of states/labels/users fail, allowing remount to retry.
- Tighten priority typing: LinearSearchFilter.priority is 0|1|2|3|4, and
  LinearPriority is the source of truth on the form-state side. Promote
  FormState from a type alias to an interface per repo convention.

* fix(linear): align update error message and clear fetchedTeams on cancel

- UpdateIssueWatch's filter-empty error still listed only the original
  four fields; bring it in line with the create path so the message
  matches what filterIsEmpty actually accepts.
- linear-issue-watch-fields: clear the fetchedTeams marker on effect
  cancellation as well as failure, so a remount after a fast teamKey
  switch can retry instead of being stuck with whatever partial cache
  the prior effect produced.

* fix(linear): address PR #974 reviewer findings

- linear-issue-watch-fields: delete the fetchedTeams marker on effect
  cleanup so rapid team switches (A → B → A) refetch on return instead
  of being stranded mid-load. Capture the Set in a local so the cleanup
  closure references the exact instance the effect body added to.
- graphql_client: raise the labels/users page cap from 100 to Linear's
  max of 250 via a shared linearMaxPageSize constant and a $first
  variable. Documented that a workspace exceeding that would need
  cursor-pagination here AND lazy-load in the UI.
- service_issue_watch.validateFilterBounds + handlers parseSearchNumeric
  Filters: reject negative estimate values — Linear story points are
  non-negative, so a -1 would silently match nothing.

* fix(linear): collapse duplicate hint text on States/Labels rows

When no team was selected the row showed two stacked muted lines —
the field description and the multi-select's "Pick a team…" prose —
saying overlapping things. Swap the row description based on team
selection and let the multi-select render nothing when disabled, so
exactly one line is shown in either state.

Also tightens the new fields' descriptions to match the brevity of
the existing ones (Labels, Estimate min/max).

* fix(linear): reject NaN/Inf estimates and preserve team cache after success

CodeRabbit + cubic flagged two real issues in the previous round:

- strconv.ParseFloat accepts "NaN" / "+Inf" / "-Inf" without erroring,
  and "f < 0" passes through for NaN. Without an explicit math.IsNaN /
  math.IsInf guard those values would propagate into the GraphQL
  request and the eventual json.Marshal would 500 instead of 400.
  Add the guard for both estimate_min and estimate_max, with a
  regression test covering NaN, ±Inf, negative, and inverted range.

- useTeamsAndStates was deleting the per-team cache marker on every
  cleanup, so a normal team switch evicted a healthy cache and the
  next visit re-fetched all three datasets. Track a "loaded" flag and
  only evict on cleanup when the fetch didn't complete successfully.

Also extracts the duplicated "team_key" / "team_key required" wire
strings into package-level constants per the repo's
"3+ occurrences → constant" convention.

* test(linear): add handler tests for /labels and /users endpoints

Mirrors the existing TestHTTPListStates_* pair — required team_key
returns 400, and a happy path that asserts the team_key reaches the
client and the response is 200. Closes the coverage gap CodeRabbit
flagged on the two new endpoints introduced in this PR.

* feat(linear): make priority filter multi-select

Status and label filters are already multi-select; priority being a
single Select broke the symmetry and forced single-value thinking.
Linear's own filter UI is multi-select per priority, and the GraphQL
filter takes priority: { in: [...] } natively, so the wire change is
clean.

Backend
- SearchFilter.Priority *int → Priorities []int
- buildIssueFilter emits priority: { in: priorities } when non-empty
- validateFilterBounds checks each element ∈ [0,4]
- normalizeFilter de-dupes (preserves caller order)
- HTTP query param priority → priorities, parsed as CSV
- Tests updated, including priority=0 still expressible as
  Priorities=[0] (matches "No priority" issues only)

Frontend
- LinearSearchFilter.priority → priorities: LinearPriority[]
- FormState.priority → priorities
- PRIORITY_OPTIONS dropped the (any) sentinel; "No priority" sits at
  the end of the chip row
- PriorityMultiSelect added next to State/Label multi-selects
- Priority moved to its own full-width row matching States/Labels;
  Creator promoted to a stand-alone CreatorRow

Refactor
- searchLinearIssues query-param building extracted to a flat
  table-driven helper to keep cyclomatic complexity ≤ 15

* refactor(linear): regroup watcher dialog fields and add section separators

UX pass on the watcher dialog:

- Pulled Team out into its own full-width row and paired Assignee +
  Creator in a 2-col row so the people filters sit together — the old
  Team+Assignee pairing forced a context switch between scope and
  people filters mid-form.
- Added hairlines between the four conceptual blocks (Destination /
  Filter / Automation / Prompt / Settings). Each block answers a
  different question, so a consistent rhythm helps users scan the form
  by section instead of reading one long stack.

No behaviour change; pure layout.

* refactor(linear): use shared Separator for watcher dialog section breaks

Initially extracted a local SectionDivider, but @kandev/ui/separator
(Radix-based, shadcn) already exists and is used in 14 places across
the codebase — including the sibling Jira settings page. Use that
instead so the styling tracks the rest of the app.

* refactor(linear): split parseSearchNumericFilters to satisfy cyclop

Extract priority and estimate-bound parsing into helpers so the entry
point drops below the project's max cyclomatic complexity (15).

* fix(linear): reject non-finite/negative estimates and align fmt

- validateFilterBounds rejects NaN/+-Inf in addition to negatives, so a
  watch config can't be persisted with values that would later crash
  json.Marshal. Adds focused unit tests.
- parseEstimate on the frontend treats negatives as unset so the UI
  cannot submit estimateMin/Max < 0.
- gofmt-only alignment tweak in handlers_test.go test table.
2026-05-22 10:42:42 +01:00
João Salavisa f98395f000 fix(agentctl): start per-repo workspace trackers in passthrough mode (#1002)
* fix(agentctl): start per-repo workspace trackers in passthrough mode

CLI passthrough mode starts the agent CLI via the PTY-based
InteractiveRunner instead of agentctl's startAgent / startOneShot, so the
per-repo workspace trackers created for multi-repo task roots were never
started. The bare root tracker exits immediately because it has no git
index, leaving polling completely idle — file edits made by the agent
never produced workspace-stream notifications and the Files panel stayed
stale. Move the tracker startup to instance.Manager.CreateInstance via a
new StartAllWorkspaceTrackers helper so polling fires for every repo
regardless of whether the agent runs via ACP or passthrough.

Fixes #982

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(lifecycle): serialize workspace poll-mode pushes to prevent slow-stuck mode

When a session subscribes and then immediately focuses (the normal page-load
sequence), the workspace poll aggregator used to spawn two independent
goroutines — one pushing slow, one pushing fast — whose HTTP arrival order
at agentctl was undefined. With the slow request landing after the fast
request the tracker ended up in slow mode (~30s polling) while the
aggregator's lastPushed claimed fast, and the suppress-duplicate guard then
blocked any future focus event from reconciling the divergence. Multi-repo
passthrough sessions hit this every page load: the Files panel saw
~30s-stale state even with the per-repo trackers running.

Replace the fire-and-forget goroutine per push with a single in-flight
pusher per workspace plus a last-write-wins pending slot — concurrent
callers all converge on the same pusher, which drains the queue in order
so the final queued mode is what reaches the wire last.

Regression test in manager_subscription_test.go simulates the race with an
httptest server that adds artificial latency on slow pushes; the test
fails on the old parallel-goroutine implementation and passes on the
serialized one.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(agentctl): address PR review feedback for passthrough tracker fix

- Goroutine leak: extract stopWorkspaceTrackers() and call it
  unconditionally at the top of Manager.Stop(). Previously the early
  return when status==Stopped skipped tracker teardown in passthrough
  mode (where Start() never runs), leaking 2 goroutines per per-repo
  tracker per stopped instance. stopShellAndProcesses delegates to the
  same helper.
- Trim multi-line doc comments to one-liners per CLAUDE.md (the
  StartAllWorkspaceTrackers docstring and the CreateInstance call-site
  comment).
- Replace the fixed time.Sleep in the race regression test with a poll
  on len(calls), exiting as soon as the pusher has drained, plus a
  brief settle window after to keep the test sensitive to the late
  racing slow push the fix guards against.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): refresh expanded subtrees on polling-detected file changes

The agentctl workspace tracker emits a generic `refresh` event with empty
path when its poll cycle detects file changes (it knows something changed
but not which file). The Files panel previously only refreshed the root
folder on these events, so files added under an expanded subdir never
became visible — the merge preserved the stale subtree, and the user had
to manually collapse + re-expand to see new files.

This is the user-visible symptom of #982 (Files panel does not refresh
after agent makes changes in CLI passthrough mode): the backend fixes in
this PR get the events flowing, but the frontend needs to act on them
correctly. On a `refresh` event we now refresh every currently-expanded
folder in addition to the root; for multi-repo task roots we scope to
the affected repo's branch using `repository_name`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address iteration-2 review feedback

- Trim doc + inline comment blocks per CLAUDE.md (manager.go,
  file-browser-hooks.ts, workspace-files.ts).
- Replace the hand-rolled fmtSscanf/numErr port parser with strconv.Atoi
  in the race regression test.
- Assert on slowCalls in the race test to lock in the queue-collapse
  guarantee (at most one slow push lands).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address iteration-3 review feedback

- Remove redundant stopWorkspaceTrackers call from stopShellAndProcesses
  (it's already invoked at the top of Manager.Stop), and drop the now-
  misleading "stopping workspace tracker" debug log from the regular
  shutdown path.
- Trim three multi-line comment blocks in manager_subscription.go to
  one-liners per CLAUDE.md (workspacePushTarget, pushAsync, pushLoop).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: trim multi-line comment block on pendingPush field

* fix: trim test docstring on race regression test

* fix(web): preserve sibling-repo subtrees on scoped refresh

The setTree reducer ran patchNode(updated) at the root, which re-matched
folderUpdates.has("") and overwrote the already-merged root children with
the raw depth-1 server response — wiping the loaded children of every
top-level repo that wasn't part of the refresh scope.

Recurse over updated.children instead of updated itself: the root has
already been merged in the block above, and each top-level repo either
gets its targeted update (if folderUpdates.has(path)) or is left alone
(preserving its loaded subtree).

Includes a regression test that fails on the old code: a refresh scoped
to "thm" used to discard "kandev"'s loaded README.md child.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): trim three more comment blocks to one-liners

* fix: trim five more comment blocks to one-liners

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 01:41:56 +01:00
João Salavisa 3c7b6c3d93 fix(loginpty): drain PTY before close on natural child exit (#1004)
* fix(loginpty): drain PTY before close on natural child exit so last bytes aren't lost

The supervise loop closed the PTY master immediately after the child
exited, racing with readLoop's blocked Read. Go's poller cancels the
in-flight Read with "file already closed" and the kernel's buffered
bytes are dropped — observed as TestStart_RunsCommandAndExits flaking on
CI with `expected 'hello' in output, got ""`. Give readLoop a 500ms
grace window to drain naturally (Unix master sees EIO once the slave
closes); only force-close if drainage doesn't complete (Windows ConPTY,
which never delivers EOF on child exit).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(loginpty): always close PTY after drain window so the master fd doesn't leak

The previous revision only called sess.stop() in the timeout branch of
the drain select, so on the Unix happy path (readDone fires first) the
PTY master fd stayed open until GC ran the *os.File finalizer. Move
stop() outside the select — by the time it runs readLoop has already
returned, so there's no in-flight Read to race with — and switch the
fallback to time.NewTimer with Stop() to release the timer eagerly when
readDone wins. Addresses review feedback from CodeRabbit, Claude,
Greptile, and cubic on #1004.

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>
2026-05-22 00:41:50 +01:00
João Salavisa f39e7b06ca fix(agent): honor session passthrough snapshot when starting agent process (#1003)
* fix(agent): honor session passthrough snapshot when starting agent process

Tasks created in agent (ACP) mode got stuck in "starting" after toggling
the profile's cli_passthrough flag and restarting the server.
StartAgentProcess re-resolved the live profile each launch, so the new
flag rerouted existing ACP sessions into the passthrough path (and vice
versa), where the workspace plumbing didn't match.

Snapshot session.IsPassthrough onto LaunchAgentRequest /
lifecycle.LaunchRequest / AgentExecution and let StartAgentProcess use
that as the source of truth. Sessionless launches (legacy
controller.LaunchAgent) still fall back to live profile resolution.

Fixes #981.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(agent): make passthrough snapshot the sole routing signal in StartAgentProcess

Bot review feedback follow-up (#1003):

- Route on the snapshot alone (new routePassthrough helper). A transient
  ResolveProfile error no longer silently routes a passthrough session
  to ACP — the fresh path surfaces an explicit error, the resume path
  surfaces the existing resolvePassthroughAgent error.
- Add TestStartAgentProcess_PassthroughResume_SurvivesProfileResolveError
  and TestStartAgentProcess_FreshPassthrough_SurfacesProfileResolveError
  to lock the no-silent-ACP-fallback invariant.
- Add TestLaunchPreparedSession_PropagatesIsPassthrough mirroring the
  resume-path propagation test so both build paths are independently
  covered.
- Strip issue-number references from source/test comments per project
  convention.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(agent): avoid double ResolveProfile in sessionless passthrough path

routePassthrough now returns (bool, *AgentProfileInfo). When the
sessionless fallback path already resolved the profile, it threads it
back so StartAgentProcess can reuse it for command building instead of
calling ResolveProfile a second time. Snapshot-driven routing still
returns nil so the snapshot path resolves fresh — the snapshot
deliberately outlives any particular live profile state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(agent): extract startPassthroughExecution to satisfy nestif

CI's golangci-lint flagged the nested-if complexity of the passthrough
dispatch block in StartAgentProcess after the previous round of fixes
pushed it past nestif's threshold of 5. Move the resume-vs-fresh
branching plus the lazy profile resolution into a startPassthroughExecution
helper so StartAgentProcess becomes a single short routing decision.

No behavior change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: drop caller reference from startPassthroughExecution comment

Per project convention, source comments shouldn't name the calling
function — the reason it's its own method is captured by its contract
(profileInfo nullability + routePassthrough handoff), not by the
caller's name.

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>
2026-05-21 23:39:23 +01:00
João Salavisa 0f1081d423 feat(share): add public share links via GitHub Gists (#995)
* feat(share): add public share links via GitHub Gists

Adds a Share button to the chat panel header that publishes the
current session as a secret GitHub Gist on the user's account. The
share link routes through gistpreview.github.io to render share.html
as a styled kandev-themed page; snapshot.json + README.md ride along
for machine-readable + fallback views.

Snapshots run a redaction pass (absolute paths, common credential
shapes, .env contents, shell env vars) and strip <kandev-system>
prompt blocks via the shared sysprompt helper. Message filtering
whitelists user prose + tool_* types so internal noise never leaks
into shares.

Spec at docs/specs/public-share-links/spec.md.

* fix(share): coerce pendingClarification to bool for ClarificationSection

A late refactor extracted ClarificationSection with `pendingClarification: boolean`
on its props, but the call site passes the actual Message|null value. CI's tsc
build catches the mismatch; runtime was unaffected because the section only uses
the value in a truthiness check. Coerce with Boolean() at the call site to keep
the prop's intent narrow.

* fix(share): address PR #995 review feedback

Bugs (flagged by Greptile, Cubic, Claude):
 - abs-path redaction split on `root + "/"` so sibling dirs like
   /home/user/proj2 don't get corrupted when workspaceRoot is
   /home/user/proj; regression test added.
 - GHClient.DeleteGist now wraps "HTTP 404" stderr as *GitHubAPIError so
   share.IsAlreadyGone matches consistently with the PAT client; this
   restores the "gist already revoked upstream" soft-success path for
   CLI-authenticated users.
 - SnapshotSizeBytes is now measured from the same MarshalIndent payload
   the gist backend uploads, so the size shown in the UI matches the
   actual gist bytes (was ~20-40% underreported).

Polish (flagged by CodeRabbit, Cubic):
 - Tool name + tool output now HTML-escaped; tool call args and output
   render via <pre><code> instead of triple-backtick fences so payloads
   containing ``` can't break out of the code block.
 - share-list.tsx: clear `busy` via finally and break error layout out
   of the justify-between row.
 - use-shares.ts: convert UseSharesResult to interface (repo TS rules);
   reset error+isLoading when ids are missing.
 - use-shares.test.tsx: wait on the actual async outcome (shares
   populated, listShares called) instead of isLoading flipping false.
 - provider.go: replace `repo.Close` cleanup with an explicit noop
   closure so the comment ("Cleanup is a no-op") and the impl match.
 - spec.md: aligned the shareability rule across What/Failure/Scenarios/
   Out-of-scope — any non-prehistory state can be shared; added language
   tags to fenced code blocks for markdownlint.

Skipped intentionally (reply on each thread):
 - HTML-escape every metadata badge: the values are controlled enum
   strings from kandev's own schema, not user input. Defense-in-depth
   noise. Tool name (which can come from agent runtime) IS escaped.
 - useShares request-token guard against stale responses: the dialog
   owns the hook lifecycle and opens against a single (taskId,
   sessionId) — the race the comment describes can't occur here.

* fix(share): redact paths for every worktree, not just the first

A single TaskSession can carry multiple worktrees when the task spans
multiple repos. The redactor previously only saw the first worktree
path, so absolute paths from secondary worktrees leaked into public
shares instead of being rewritten to repo-relative form.

NewRedactor is now variadic and applies the abs-path rule against every
root it receives, sorted longest-first so a root that is a prefix of
another (e.g. /workspace vs /workspace/repo1) gets matched by the more
specific one first. workspaceRootFor in builder.go becomes
workspaceRootsFor and emits every WorktreePath plus the WorkspacePath
fallback.

Flagged by Cubic on PR #995.

* fix(share): address PR #995 review round 3

Bugs (flagged by Cubic + Claude on gh_client.go:714):
 - DeleteGist now calls the existing isNotFoundErr helper instead of an
   inline strings.Contains(err, "HTTP 404"). The helper also covers
   "404 Not Found" and "status: 404", which some gh versions emit, so
   the share service's "gist already revoked upstream" soft-success
   path now fires consistently across CLI auth users.

Bugs (flagged by Claude on builder.go:220):
 - toolCallBlock used to read metadata["tool_name"] and metadata["args"]
   but production never writes those keys; the typed payload lives under
   metadata["normalized"] (a *streams.NormalizedPayload) and the tool
   kind is on Message.Type. Switched to deriving ToolName from
   strings.TrimPrefix(m.Type, "tool_") (gives "read", "execute", "edit",
   …) and reading args from metadata["normalized"], with backward-compat
   fallback to the older "tool_name"/"args" keys for the debug-fixture
   replay path. New TestBuildSnapshot_ToolCall_ProductionMetadataShape
   exercises the real session shape.

Bugs (flagged by CodeRabbit on redaction.go:110):
 - Bare workspace-root occurrences (pwd output, end-of-sentence, quoted
   paths) used to leak because the matcher only handled root + "/".
   Switched to a precompiled regex per root —
   regexp.QuoteMeta(root) + `(?:/|\b)` — so both `root/...` and standalone
   `root` get stripped, while sibling dirs like /workspace2 still don't
   match. Test TestRedactor_AbsPath_StandaloneRootStripped covers the
   bare-root variants.

Doc (flagged by Claude on spec.md):
 - Two malformed fence closers (`go and `text on closing fences) corrected
   to plain ``` so the file is markdownlint-clean.

* fix(share): narrow abs-path boundary to avoid sibling-prefix corruption

Round 4 review (Claude + CodeRabbit) caught a regression in the regex
introduced by 436cb66: `(?:/|\b)` used `\b` (word boundary) to catch
bare-root cases, but `\b` fires on any word↔non-word transition. So a
sibling path like `/workspace.bak`, `/workspace-old`, or
`/workspace-other` would false-match the `/workspace` root (since
e→. and e→- are word boundaries), stripping it and leaving a
corrupted `.bak` / `-old/file.ts` in the share.

Replaced the matcher with `(/|\s|$)`: path separator, whitespace, or
end-of-string only — never punctuation or quote chars. The boundary
is captured and re-emitted via ReplaceAllStringFunc (or dropped, in
the `/` case) so prose loses only the root, not the surrounding space.

Added TestRedactor_AbsPath_SiblingsWithPunctuationNotCorrupted covering
`.bak`, `-old`, `_alt` siblings; the digit-sibling test still passes.
The quoted-bare-root case (`"/workspace"`) no longer matches and was
dropped from the standalone-root test — contrived enough that a small
leak there is preferable to corrupting realistic sibling paths.

* refactor(share): move share button into chat status bar

Replaces the dedicated PanelHeaderBarSplit above the message list with
a slot in ChatStatusBar (the existing row holding TodoIndicator and
PRStatusChip just above the chat input). The button anchors right via
ml-auto; when the "proceed to next step" affordance is present it
takes the right anchor instead and the share button stays inline.

Also tightens the iconOnly variant in ShareButton to a ghost style
that matches the muted-foreground chips in the status bar, and exports
shareableSessionStateClient from share-button.tsx so the gating logic
lives next to the entry point it controls.

Net -23 lines in task-chat-panel.tsx: the extra header bar and the
local gate helper are gone.

* docs(share): broaden tooltip + add share/ to CLAUDE.md tree

The share tooltip read "Share this completed task with a public link",
but shareableSessionStateClient allows RUNNING / IDLE / WAITING_FOR_INPUT
/ FAILED / CANCELLED too. A user hovering mid-run saw misleading copy;
reworded to "Share this task conversation as a public link" which
matches the actual gate.

Also added the missing internal/task/share/ entry to the task/ subtree
in CLAUDE.md so the new package shows up in the repo map — flagged
across multiple rounds.

* docs: add internal/task/share/ to repo map

Carryover from the share PR — AGENTS.md (which CLAUDE.md symlinks to)
was missing the share/ subpackage under task/. Adding it now so the
repo map matches what's checked in.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-21 23:35:41 +01:00
João Salavisa b8a09bc5ea fix(github): pick allowed merge method so squash-only repos stop 405ing (#999)
* fix(github): pick allowed merge method so squash-only repos stop 405ing

The merge button sent an empty merge_method to GitHub, which defaults to
"merge" regardless of repo settings — squash-only / rebase-only repos
returned 405 "Merge commits are not allowed on this repository". The
backend now resolves an empty method to the first allowed one via a new
GetRepoMergeMethods endpoint (5-min cache), and the frontend renders a
GitHub-style split button so users can pick among the allowed methods.

* fix(github): address PR #999 review feedback

- pr-merge-button: extract repo-methods fetch to useRepoMergeMethods
  hook (Claude bot); hide button while methods are loading so the
  primary label doesn't flicker (Claude bot); drop e.preventDefault on
  dropdown items so Radix closes the menu on selection (Greptile P1).
- gh_client / pat_client: treat missing allow_* fields as false instead
  of true so a permission-gated response can't pick a disallowed merge
  method (cubic P2).
- service.MergePR: only overwrite mergeMethod when pickDefaultMergeMethod
  returns non-empty, so a degenerate all-false config falls through to
  GitHub's default with the real error (Claude bot).

* fix(github): don't hide merge button on lookup failure

Round-2 PR feedback (Claude, Greptile, CodeRabbit, cubic): the
`if (!methods) return null` guard added in the previous round to
avoid label flicker turned a transient repo-methods lookup failure
into a permanent UI lockout — the button would stay hidden until
unmount/remount.

- pr-merge-button: revert the null-guard; treat null as "use backend
  resolver", render the plain primary button without a dropdown.
- use-repo-merge-methods: add a 5-min frontend TTL to match the
  backend cache so admin merge-strategy flips are visible without a
  page reload. Catch block now triggers a rerender, and co-waiting
  consumers attach both fulfilled/rejected handlers to avoid silent
  rejection swallowing.

* test(github): cover useRepoMergeMethods + silence rejection warning

Round-3 PR feedback (Claude):

- Swallow rejections in the originating fetch chain instead of
  rethrowing, so the first consumer's promise never escapes the hook
  as an unhandled rejection. Coalesced consumers already re-render to
  null, which is the same state a fresh "not loaded" call would show.
- Add use-repo-merge-methods.test.ts covering: null-on-missing-params,
  loading then resolution, cache hit on remount, concurrent-fetch
  coalescing, retry-on-failure (no cache poisoning, no unhandled
  rejection), and TTL-expiry refetch via fake timers.

* test(github): cover httpGetRepoMergeMethods error branches

Round-4 PR feedback (Claude): the new endpoint had only happy-path
coverage. Add tests matching httpMergePR's existing shape:

- nil client → 503 (ErrNoClient)
- GitHubAPIError{404} → 404
- generic upstream error → 500

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-21 23:22:31 +01:00
João Salavisa f8654880f3 fix(kanban): stop stuck spinner by writing task.state=REVIEW on engine on_turn_complete (#1001)
* fix(web): stop kanban spinner when primary session is terminal

The kanban card spinner was gated only on `task.state` being IN_PROGRESS
or SCHEDULING. Workflows leave a task in the IN_PROGRESS column after
the agent's session reaches a terminal state (COMPLETED/FAILED/CANCELLED)
so the user can review and move it manually, which made the loading
icon spin indefinitely.

The frontend already receives `primary_session_state` over SSR and WS;
gate the spinner on that not being terminal. Fixes #985.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(orchestrator): write task.state=REVIEW after engine on_turn_complete transition

The non-engine path (setSessionWaitingForInput → writeTaskReviewState) has
always written tasks.state = REVIEW when the agent's session paused for
input. The workflow-engine on_turn_complete path forgot the task-state
write: it persisted the new workflow step + flipped the session to
WAITING_FOR_INPUT but left tasks.state stale at IN_PROGRESS. That left
the kanban card stuck in IN_PROGRESS — with a spinning loader — even
after the workflow had moved it into the "Review" column.

Mirror the non-engine path: call writeTaskReviewState inside the
RUNNING/STARTING branch of applyEngineTransition. Add a regression test
that asserts tasks.state = REVIEW after an engine-driven on_turn_complete
transition.

Also tighten the frontend gate added in the previous commit to suppress
the spinner for any non-active primary-session state (WAITING_FOR_INPUT,
IDLE in addition to COMPLETED/FAILED/CANCELLED). The backend fix is the
real correction; the broader frontend gate stays as defense-in-depth
against future state-sync drift.

Refs #985.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(orchestrator): drop redundant time.Sleep in engine on_turn_complete test

writeTaskReviewState and ApplyTransition both run synchronously inside
applyEngineTransition (before processOnEnter's goroutine), so the
task-state and step assertions need no async wait. assertSessionState
already has its own 500ms polling loop for the WAITING_FOR_INPUT flip.

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>
2026-05-21 22:58:14 +01:00
João Salavisa 76143f8159 fix(acp): restore ScheduleWakeup wire string broken by Wakeup→Run rename (#998)
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>
2026-05-21 21:29:30 +01:00
Jérémy Coatelen b39f256e84 fix(orchestrator): propagate RepositoryID so worktree setup script runs (#969) 2026-05-21 10:09:20 +01:00
Carlos Florêncio 2ca48fc7df feat(repositories): guard delete behind active-session check + UI fixes (#968) 2026-05-21 09:24:34 +01:00
José Almeida 1f264adbc4 fix(github): drain piled-up PR/issue review tasks via cleanup policy + manual sweep (#929) 2026-05-21 08:46:50 +01:00
João Salavisa 3d860290da feat(github): add merge button when PR is ready to merge (#979)
* feat(github): add merge button when PR is ready to merge

Surfaces a one-click merge action in the PR detail panel header and as a
compact green pill in the PR topbar hover popover, gated by the existing
isPRReadyToMerge predicate (open, CI success, mergeable_state=clean, and
either approved or no review process).

Backend adds MergePR to the Client interface, implements it across
PAT/gh/Noop/Mock clients, exposes PUT /api/v1/github/prs/:owner/:repo/:number/merge,
and maps GitHub 405/409 to HTTP 409 so the UI can show a clear error.

* fix(github): address PR review feedback on merge endpoint

- Map gh CLI 404/403/405/409 stderr to *GitHubAPIError via a new
  ghMergeStatusCode helper, so merge conflicts surface as HTTP 409 for
  gh CLI users (previously only PAT users got the right status).
- Service.MergePR returns ErrNoClient when unconfigured; httpMergePR
  translates that to HTTP 503 with github_not_configured, matching
  httpListRepoBranches.
- httpMergePR distinguishes empty body (io.EOF, fall through to default
  merge method) from malformed JSON (HTTP 400 invalid payload).
- PRMergeButton tracks a local merged flag so the button stays hidden
  across the async refresh window after a successful merge.

* test(github): cover merge helpers and extract PRMergeButton

- TestGhMergeStatusCode locks in the gh CLI stderr → status-code parser
  for nil, unrelated errors, the HTTP / status: / bare-phrase forms of
  404/403/405/409, and a 500 non-match guard.
- Broaden ghMergeStatusCode to also match "405 Method Not Allowed" /
  "409 Conflict" phrase forms so the merge endpoint's 409 mapping does
  not depend on the gh CLI keeping its current "HTTP <code>" prefix.
- TestHttpMergePR_MalformedJSON covers the new non-EOF bind-error branch
  (truncated body → 400). TestHttpMergePR_NoClient_Returns503 covers the
  ErrNoClient → 503 path on Service.MergePR.
- Document the post/put error-type divergence on PATClient.post so a
  future caller does not silently lose per-status mapping.
- Move PRMergeButton out of pr-shared.tsx into its own file, and reset
  the local "merged" flag when taskPR.id changes so an instance that
  re-renders for a different PR does not stay hidden.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-21 03:05:28 +01:00
João Salavisa 0b2c59a2dd fix(lifecycle): publish AgentReady on wakeup-driven turns (#875)
* 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>
2026-05-21 01:37:03 +01:00
João Salavisa 569a0d77bd fix(office): unbreak new-task dialog "Create Task" button (#976)
* fix(office): unbreak new-task dialog "Create Task" button

Office task creation silently failed: the button click sent a malformed
payload, the backend dropped it, and no toast surfaced because Sonner's
Toaster was never mounted. Three causes, all fixed here.

- web: send priority as a string id ("medium") instead of a number; the
  backend Priority field is a string and ShouldBindJSON was rejecting
  the body with "invalid payload".
- web/backend: add project_id as a top-level field on createTask. The
  dialog was burying it inside metadata where the HTTP handler never
  read it, so isOfficeRequest() returned false and validation tripped
  on "workflow_id is required for non-ephemeral tasks".
- web: mount the Sonner Toaster in the root layout. All sonner-based
  toasts across the app (not just this dialog) were silently dropped
  because no Toaster was rendered anywhere.

* fix(office): address PR #976 review feedback

- new-task-dialog: drop unreachable `|| undefined` guard on priority
  (EMPTY_DRAFT.priority is always "medium" and the picker only sets
  non-empty strings, so the fallback can never fire).
- layout: move sonner Toaster to top-right so it stops visually
  overlapping with the custom ToastProvider's bottom-right stack.
- task_http_handlers_test: add regression test asserting that a
  create-task body with project_id (and no workflow_id) reaches the
  office code path and the office workflow auto-resolves. Guards the
  exact wiring that caused the original silent failure.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-21 01:14:08 +01:00
Luan Martins a852be3c33 feat(web): show task linkage on GitHub PR list (#952)
* feat(web): show task linkage on GitHub PR list

Add a compact indicator next to each PR showing whether a task already
exists. When tasks are linked, show a clickable button with the task
title and a count badge for multiple tasks. When no task exists, show
'No task created yet'.

- Add usePRKeyToTasks hook that maps PR keys to task associations
- Add PRRowTaskIndicator component for the per-PR task UI
- Wire the data through github-page-client into PRList
- Add tests for the indicator and hook utility

* feat(web): improve PR task indicator with task titles and multi-task dropdown

- Show actual task title instead of PR title in single-task case
- Replace count badge with dropdown menu for multiple linked tasks
- Switch icon from GitPullRequest to Checklist
- Load all workflow snapshots on GitHub page to hydrate task titles
- Fix test to use unique task_id per fixture

* feat(web): neutralize PR task indicator color and add tooltip

The checklist icon previously mirrored PR aggregate status, which duplicated
information already shown on the PR row and misleadingly suggested it
reflected task state. Drop the color and add a tooltip surfacing the actual
task title and workflow step.

Adds e2e coverage for the GitHub PR list task indicator: empty badge,
single-task button, and tooltip content.

* fix(web): navigate to task page when clicking PR task indicator

replaceTaskUrl only mutates history.state, which sets active task in the
store but does not trigger a Next.js route change. From /github, this left
the URL pointing to /t/<id> while the page still rendered the GitHub list.
Use router.push so the click actually navigates to the task.

* test(web): cover PR task indicator click navigation

Extends the GitHub PR list task indicator spec with a click assertion
verifying the URL changes to /t/<taskId> and the PR list unmounts.
Regression guard for the replaceTaskUrl → router.push fix.

* fix(github): link task to PR at creation time, not via branch poller

Tasks started from the GitHub PR list "+ Task" button were left without a
github_task_prs row when the worktree branch did not match the PR's head
branch on GitHub. This happened for PR-review-style tasks, which check out
synthetic branches (e.g. feature/review-...-h92) that don't exist as PR
heads. The poller's FindPRByBranch could never resolve them, so only the
first task whose branch happened to match got linked — every other task
created from the same PR was missing from the indicator.

The PR is already known at "+ Task" time, so persist the linkage
immediately rather than waiting on branch-based discovery:

- AssociateExistingPRByURL: new service method that creates the
  github_task_prs row from a PR URL without creating a PR watch. The
  watch is still created later at agent start via ensureSessionPRWatch.
- POST /api/v1/github/task-prs/associate: HTTP endpoint exposing it.
- QuickTaskLauncher.handleSuccess: after task creation, fires a
  best-effort associate call with the task's repository_id and PR URL.
  Failures fall back to the legacy poller path silently.

Backend test covers two distinct tasks linking to the same PR.
E2e adds a PR with two tasks and asserts the multi-task indicator
("Tasks 2") renders alongside the single-task and empty cases.

* refactor(github): make task-PR create endpoint RESTful

Rename POST /api/v1/github/task-prs/associate to POST /api/v1/github/task-prs.
The action-named "/associate" was RPC-styled; the resource collection is the
right URL for creation alongside the existing GET. Frontend client renamed
associateTaskPR → createTaskPR to match.

* fix(github): address PR review findings

- Map invalid PR URL to HTTP 400 (was 500). New ErrInvalidPRURL sentinel
  lets the controller distinguish parse failures from genuine server
  errors. Other failure paths still return 500.
- Prefer dialog-time repositoryId in pickRepositoryIdForPR. Multi-repo
  tasks with shared branch names were ambiguous under pure branch
  matching; the matched repo id captured by the launcher dialog wins
  when present.
- Cover usePRKeyToTasks (not only the prKey util) with hook tests:
  empty input, multi-task grouping, distinct keys, and the non-array
  defensive branch.
- Lock createTaskPR request semantics: method/body now win over a
  caller's options.init so the POST JSON shape can't be overridden.

* fix(web): use TaskPR row id for dropdown key

Multi-repo tasks can produce more than one TaskPR row for the same PR
(one row per repository_id), so keying the multi-task dropdown by
task_id risked a duplicate-key warning and unstable reconciliation.
Switch to the row id, which is unique per row.
2026-05-20 22:38:44 +01:00
Carlos Florêncio a74409af3c fix(web): cap clarification overlay height and add mock agent ask commands (#956) 2026-05-19 22:27:24 +01:00
Carlos Florêncio 8467dd94f8 fix(ws): detach dispatched handler ctx from WS connection lifetime (#959) 2026-05-19 21:43:45 +01:00
irium 9fd789ba04 fix(executor): persist worktree subdir as workspace_path (#958) 2026-05-19 20:49:00 +01:00
Carlos Florêncio b73c00f054 fix(backend): skip redundant task-state writes when session state is unchanged (#953) 2026-05-19 18:27:16 +01:00
Carlos Florêncio 2bfdd518b6 fix(web): improve inline code chip visibility in markdown (#948) 2026-05-19 17:56:35 +01:00
José Almeida 7115ee08d5 fix(acp): stop inventing currentModelId from AvailableModels[0] (#947)
* 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.
2026-05-19 14:18:51 +01:00
Carlos Florêncio 59ea5b7106 fix: respect user-chosen agent on workflow step transitions (#941) 2026-05-18 23:18:57 +01:00
Carlos Florêncio adcde7c89e fix(backend): persist kandev-system wrap on first task prompt (#940) 2026-05-18 20:08:15 +01:00
Carlos Florêncio af68c0a8f5 chore: suppress pnpm modules-purge prompt in non-interactive installs (#932) 2026-05-18 19:59:10 +01:00
Carlos Florêncio f6bd83da33 refactor: tighten type system across backend + frontend (#920) 2026-05-17 20:16:15 +01:00
Carlos Florêncio e346572371 chore(deps): migrate ACP SDK from agentcooper fork to upstream v0.13.0 (#921) 2026-05-17 19:51:24 +01:00
Carlos Florêncio 0b2085688b feat(office): autonomous agent management layer (#914) 2026-05-16 20:07:57 +01:00
Carmelo Campos aea16e166d fix: ignore pi version banner in utility inference output (#915) 2026-05-16 14:27:48 +01:00
Carlos Florêncio a5108de926 fix(windows): make conpty Close idempotent and kill orphan agentctl (#900) 2026-05-15 16:04:35 +01:00
José Almeida e144e8c530 fix: improve custom script menu readability (#905)
* fix: improve custom script menu readability

* chore: address verification drift

* chore: address custom script menu review
2026-05-14 23:57:05 +01:00
José Almeida f3397b4ad7 feat: docker and sprites executor improvements (#738)
* feat(orchestrator): generalize task environment reuse across executor types

Worktree, container, and sandbox IDs now flow uniformly from a persisted
TaskEnvironment back into LaunchAgentRequest through a single helper. This
gives Sprites session-to-session reuse the same first-class treatment that
Docker already gained from the GitHub-token-injection PR, and lets a fallback
launch refresh the environment row instead of leaving stale IDs in the DB.

- applyExistingEnvironment propagates WorktreeID, ContainerID (via metadata),
  and SandboxID (as sprite_name metadata) onto the launch request
- persistTaskEnvironment refreshes those IDs when the executor ends up
  creating new resources (e.g. the previous container was pruned)
- extractSandboxID lifts the sprite identifier out of executor response
  metadata so it can be persisted on the TaskEnvironment row

* feat(backend): add task-level reset environment action

Users sometimes need a clean slate — broken packages, stale caches, a
container that drifted into a bad state. Reset Environment is the explicit
escape hatch: tear down the recorded container, sandbox, and/or worktree
for a task and delete the TaskEnvironment row so the next session starts
fresh.

- service.ResetTaskEnvironment(taskID, ResetOptions) is the entry point.
  ResetOptions.PushBranch optionally git-pushes the workspace branch
  before teardown so committed work isn't dropped on the floor; a failed
  push aborts the reset and leaves the environment intact.
- A new EnvironmentDestroyer interface in the task service hides the
  per-resource teardown behind a single seam, implemented in cmd/kandev
  by delegating to lifecycle.Manager (containers, sprites) and
  worktree.Manager (worktrees, branch preserved).
- lifecycle.Manager.DestroyContainer / DestroySandbox dispatch to the
  Docker and Sprites executor backends without leaking model types into
  the lifecycle package.
- Reset is blocked while any session on the task still has an
  ExecutorRunning row (returns ErrSessionRunning → HTTP 409). Per-resource
  teardown is best-effort but the DB row is only removed once everything
  is confirmed gone or already missing, so a partial failure is retryable
  by clicking Reset again.
- Endpoint: POST /api/v1/tasks/:id/environment/reset.

* feat(web): add executor settings popover with reset environment action

A new button on the task top bar opens a popover showing the active
TaskEnvironment — executor type, status, container/sprite/worktree
identifiers, and a copy-ready 'docker exec' command for local Docker.
The popover hosts the destructive Reset Environment action so users have
one obvious place to inspect and manage the environment without
cluttering the New Session dialog.

The reset confirmation dialog requires explicit acknowledgement that
uncommitted changes will be lost. When the environment has a host-side
worktree it offers an opt-in 'push branch before resetting' checkbox so
committed work can be pushed first; the backend aborts the reset on
push failure.

- task-environment-api.ts: fetchTaskEnvironment + resetTaskEnvironment
- executor-settings-button.tsx: popover trigger + identity panel
- task-reset-env-confirm-dialog.tsx: destructive confirm with push opt-in
- task-top-bar.tsx: wire the new button into the right-side controls
- e2e: replace fresh-env-toggle test with reset-from-popover flow

* fix(docker): expand {workspace} placeholder in container WorkingDir

The agent runtime config carries `WorkingDir: "{workspace}"` for most ACP
agents. Mount sources were already expanded via expandMountSource, but
WorkingDir was passed through verbatim, so Docker rejected new containers
with:

  Error response from daemon: the working directory '{workspace}' is
  invalid, it needs to be an absolute path

Run the same expansion on WorkingDir, defaulting to /workspace when no
host WorkspacePath is set (the clone-inside-container case introduced by
PR #654).

* fix(docker-profile): persist Dockerfile/image_tag and apply tag override at runtime

Two bugs:

1. The Dockerfile editor and Image Tag input on the executor profile page
   stored their values in component-local useState inside DockerSections,
   not in the form state. Saving the profile never wrote them back, so
   "Use defaults" / typing in the editor / changing the tag never
   persisted — and the Build button was the only thing that consumed
   them. The same gap existed in the new-executor flow's
   buildProfileConfig.

   Lift `dockerfile` and `image_tag` into useProfileFormState (edit) and
   useCreateProfileFormState (new), and emit them in buildSaveConfig /
   buildProfileConfig so they round-trip through profile.config.

2. Even when an image_tag is set on the profile, the container builder
   ignored it. imageName was always derived from the agent runtime's
   hardcoded `kandev/multi-agent`, so a user who built a custom image
   like `kandev/agent:latest` would still see Docker fail with
   "No such image: kandev/multi-agent:latest".

   Propagate `profile.config.image_tag` → metadata
   (`image_tag_override`) → ExecutorCreateRequest → ContainerConfig,
   and let it override `rt.Image` in buildContainerConfig when set.

Also align the frontend default image tag (kandev/multi-agent:latest)
with the backend's hardcoded runtime default so users who don't override
end up with a working build.

* test(docker): cover container config build + Docker profile persistence

Adds two regression tests for the recent Docker fixes:

- container_config_test.go (Go unit): asserts buildContainerConfig
  expands `{workspace}` in WorkingDir (defaulting to /workspace when no
  host WorkspacePath is set) and that ContainerConfig.ImageTagOverride
  takes precedence over the agent runtime's hardcoded `rt.Image`. No
  Docker daemon required.

- docker-profile-persistence.spec.ts (Playwright E2E): creates a
  local_docker executor + profile, opens the profile edit page, clicks
  "Use defaults" to populate the Image Tag input + Dockerfile editor,
  hits Save, then refetches the profile via API and reloads the page to
  verify both fields round-trip through `profile.config`. Catches the
  prior regression where DockerSections owned the form state locally
  and never wrote it back. Runs against the existing E2E backend that
  has KANDEV_DOCKER_ENABLED=false — we only test the persistence path,
  not container creation.

Adds an api-client helper (`getExecutorProfile`) for the round-trip
assertion.

* feat(docker): self-contained Dockerfile + mount agentctl + capabilities adapter

Make the Docker executor work with a Dockerfile users can actually build,
without depending on a maintainer-shipped image:

- The container now launches a small bootstrap (`sh -c "$KANDEV_PREPARE_SCRIPT
  ; exec /usr/local/bin/agentctl"`) regardless of the image's ENTRYPOINT.
  The agent CLI is no longer wired through container Cmd; agentctl receives
  it later via its CreateInstance HTTP API.
- Host agentctl-linux binary is mounted into the container at
  /usr/local/bin/agentctl via the existing AgentctlResolver. User-built
  images don't have to bake it in.
- Default Dockerfile in the executor profile UI replaced with a working
  node:22-slim + git + curl image — runs npx-based ACP agents (Claude,
  Codex, OpenCode, Auggie) out of the box.
- lifecycleAdapter now exposes RequiresCloneURL / ShouldApplyPreferredShell
  so the orchestrator can correctly identify containerized executors and
  resolve the clone URL. Without this, applyRepositoryConfig short-circuits
  on `e.capabilities == nil`, the cloneURL is never resolved, and the
  prepare script fails with `repository '{{repository.clone_url}}' does
  not exist`.
- applyRepositoryConfig also surfaces the resolved clone URL to script
  metadata so the prepare script can interpolate {{repository.clone_url}}.
- docker.ContainerConfig gains an Entrypoint field, plumbed to the docker
  client.

Verified end-to-end on a local stack with the new minimal image: the
container starts, bootstrap clones the GitHub repo, agentctl launches and
hands off to claude-acp inside the container.

* fix(docker): patient health check + resilient bootstrap

Two follow-ups to the Docker bootstrap work:

- The 30 retries × 500ms = 15s budget for agentctl health was too tight.
  In the previous flow agentctl was a separate exec from the prepare
  script, so it came up immediately. Now it runs after the prepare
  script (which does git clone, optional installs), so first launch can
  easily exceed 15s and produce a misleading "agentctl not healthy after
  30 retries" error. Bumped to 240 retries (120s) and surfaced the last
  underlying error in the message so failures point to the real cause.

- Prepare scripts commonly start with `set -e`/`set -eu`; running them
  via `eval` propagated the option to the outer bootstrap shell, so any
  failure killed the bootstrap before `exec /usr/local/bin/agentctl`.
  Run prepare in a subshell so its `set -e` is contained, and start
  agentctl regardless. If prepare fails, agentctl still comes up — the
  host can connect, surface the failure, and let the user debug from
  the Executor Settings popover.

* fix(executor-profile): persist remote auth secrets/credentials for Docker profiles

The Remote Credentials card (per-agent secret picker, plus the GitHub
credential checkbox) is rendered for any remote executor — Sprites,
local_docker, or remote_docker — but the save path in both the create
and edit pages only emitted `remote_auth_secrets` and `remote_credentials`
when `isSprites` was true. Selecting a secret for the Claude / Codex /
Auggie ANTHROPIC_API_KEY-style entry on a Docker profile updated form
state, the dropdown showed it as picked, but Save Changes silently
dropped the value before posting to the API. On reload the agent showed
as "Not configured", and any session start failed with
`{"code":-32000,"message":"Authentication required"}`.

Switch the guards to `isRemote` so the same data persists for all remote
profile types. The backend orchestrator already propagates
`profile.config.remote_auth_secrets` and `remote_credentials` through to
metadata for any containerized executor and resolves them via
`applyContainerCredentials` → `resolveAuthSecrets`, so no backend change
is needed once the values actually land in the saved profile.

* fix(executor-profile): live Configured badge + session/load resilience + live container status

Three independent fixes that all surface together while iterating on the
Docker executor flow:

- Remote Credentials card: the per-agent "Configured" / "Not Configured"
  badge was driven by useState that only seeded once on mount. Picking a
  secret in the dropdown updated the parent's `agentEnvVars` map but the
  local `choice` never moved to `"env"`, so the badge stayed grey until
  the page was reloaded. Derive `choice` straight from props every render
  so the badge tracks live state.

- ACP session/load: when the agent process restarted between turns
  (common with claude-agent-acp because its session map is in-memory),
  the host's session/load call failed with JSON-RPC -32002 "Resource not
  found" and the entire launch errored out. Treat that as the same kind
  of "session is gone" signal as -32601 / capability=false and fall back
  to session/new. Worst case the agent loses prior context; best case
  the user just keeps typing.

- Executor Settings popover: dropped Execution ID and Environment ID
  rows (debug-only noise users don't need), kept Container ID + the
  copyable `docker exec -it … bash` line, added a live status badge
  with tones for running / exited / paused / dead / error / missing,
  and a 3-second polling interval so the badge follows the real
  container state. New `GET /api/v1/tasks/:id/environment/live`
  endpoint plumbs Docker `inspect` data through the lifecycle manager
  → task service → handler. ContainerLiveStatus mirror type at the
  service layer keeps lifecycle types out of the public surface.

* feat(web): gate agent profiles by executor profile credentials

Filter the agent profile dropdown in both the Create Task dialog and the
New Session dialog to only show agents that are actually configured for
the selected executor profile.

For local/worktree executors, no gating (agents need no extra creds).
For Docker/Sprites profiles, an agent is "configured" iff one of its
remote-auth methods has a value in `remote_credentials` or a non-null
secret in `remote_auth_secrets`. Agents without an entry in the remote
auth catalog (Copilot, Amp, OpenCode, TUI, mock) cannot carry creds on
remote executors and are blocked.

Spec IDs match `AgentProfileOption.agent_name` (the registry-type string
like "claude-acp"); `agent_id` is a DB UUID and unrelated to the catalog.

The Create Task footer surfaces a "No agent profile is configured for X"
tooltip and disables submit when the selected executor profile has no
compatible agents. The New Session dialog auto-swaps to the first
compatible agent and shows the same banner inline.

`useRemoteAuthSpecs` returns a `loaded` flag so callers don't filter
during the initial fetch (would otherwise hide everything).

* feat(docker): make mock-agent runnable inside Docker for E2E

Lay the backend groundwork so the existing mock-agent can run inside
local_docker containers — needed for real-Docker E2E tests that don't
require Anthropic/OpenAI API keys.

Container builder bind-mounts a host mock-agent binary at
/usr/local/bin/mock-agent when KANDEV_MOCK_AGENT_LINUX_BINARY is set
(or build/mock-agent-linux-amd64 sits next to the kandev binary), the
same pattern already used for agentctl. MockAgent.BuildCommand always
resolves via PATH so the same command works on host (E2E PATH includes
bin/) and in containers (mounted at /usr/local/bin/mock-agent).

Mock now declares non-nil empty RemoteAuth so it counts as a
remote-capable agent. The remoteauth catalog includes specs even with
zero methods, and the frontend gating helper treats an empty-method
spec as "no credentials needed" — letting mock-agent pass the
configure-on-executor check that hides uncatalogued agents on remote
executors.

repositoryCloneURL falls back to the local origin remote URL when the
repo has no provider info, so local-only repos with a real remote
(including file:// remotes used in E2E) work on remote executors that
clone inside the container.

Makefile: build-mock-agent-linux target mirrors build-agentctl-linux.

* test(docker-e2e): add real-Docker Playwright project + first spec

A new Playwright project — \`docker\` — runs container-bound E2E specs
against a backend with KANDEV_E2E_DOCKER=1. The fixture probes for a
Docker daemon and skips the worker entirely when none is reachable, so
contributors without Docker can still run the rest of the suite.

The fixture builds a tiny kandev-agent:e2e image once per machine
(node:22-slim + git/curl), pre-seeds a local_docker executor profile,
and seeds a local repo with a file:// origin so the executor's
clone-inside-container path resolves without network access. A small
WS-based session.launch helper is added to the api-client for tests
that need to launch additional sessions on an existing task.

First spec — docker-launch.spec.ts — covers the smoke path: a session
launches in a real container, the task environment records its
container_id, and \`docker inspect\` confirms the container exists.
Two follow-up specs (multi-session env reuse + recovery from external
container removal) are wired up but marked test.fixme with comments
pinpointing the backend gaps that block them: persistTaskEnvironment
isn't called for workflow-prepared sessions (so task_environment_id
stays empty on those rows), and DockerExecutor.reconnectToContainer
loses the agentctl handshake auth token across launches (so reconnect
401s and falls back to a fresh container every time).

Run locally with: \`pnpm --filter @kandev/web exec playwright test
--config e2e/playwright.config.ts --project=docker\`.

* ci(e2e): add dedicated Docker E2E job

The new \`docker\` Playwright project needs a Docker daemon and the
linux/amd64 agentctl + mock-agent binaries bind-mounted into containers.
Pin the existing sharded job to chromium + mobile-chrome so the docker
project no longer leaks into every shard (where it would fail seeding
without KANDEV_DOCKER_ENABLED), and add an \`e2e-docker\` job that:

- Verifies the Docker daemon is reachable on the runner.
- Downloads the same backend artifact (now including the linux helper
  binaries, built via \`make build-backend-linux-helpers\`).
- Runs \`playwright test --project=docker\` with KANDEV_E2E_DOCKER=1.
- Cleans up any kandev-managed containers at teardown so leaks don't
  poison the next run.

* ci(e2e): include docker job in merged HTML report

Upload the docker job's blob report under the \`blob-report-*\` glob the
merge job already consumes, and add e2e-docker to e2e-report's needs so
the final HTML report covers both the sharded chromium runs and the
docker run.

* ci(e2e): add workflow_dispatch for ad-hoc runs and draft-PR verification

* fix(build): always produce linux/amd64 agentctl as part of \`make build\`

Remote executors (local Docker, remote Docker, Sprites) bind-mount the
linux/amd64 agentctl binary into the container. On non-Linux hosts (e.g.
macOS dev installs) running plain \`make build\` produced only the host
binary, so the first Docker session would fail with:

  failed to build container config: agentctl linux binary not found

The top-level \`make dev\` already special-cased non-Linux hosts to chain
\`build-agentctl-linux\`, but \`make build\` and \`make start\` skipped it.
Add it as a dependency of the standard backend build so every install
ships a usable linux variant. Cross-compiling agentctl is fast (~1-2s)
and the binary is ~20 MB, so the cost is negligible.

Users who hit the error can run \`make build-backend\` (or \`make build\`
in apps/backend) to regenerate; setting KANDEV_AGENTCTL_LINUX_BINARY
remains a manual override.

* fix(release): ship linux/amd64 agentctl in every release artifact

Remote executors (local Docker, remote Docker, Sprites) bind-mount the
linux/amd64 agentctl into the container. The release pipeline only
shipped a host-arch agentctl, so users who installed via the bundled
binaries (npm/Homebrew/etc.) hit the same "agentctl linux binary not
found" error the first time they tried a Docker executor — even on
Linux, since the resolver expects the -linux-amd64 suffix.

Changes:

- .github/workflows/release.yml: cross-compile agentctl-linux-amd64 in
  the build-bundles matrix step (CGO off so it works from every host).
  Bundle ships kandev + host agentctl + agentctl-linux-amd64 in
  dist/kandev/bin/. The AgentctlResolver finds it next to the kandev
  binary automatically.
- Dockerfile: same idea for the container image — produce both variants
  in the Go builder stage and copy the linux-amd64 helper into
  /app/apps/backend/bin/ so the resolver picks it up inside the image.

A previous commit added the same dependency to \`make build\` for local
dev installs; this commit closes the gap for end users.

* fix: address PR review feedback (CI, CodeRabbit, Greptile, Claude)

Backend:
- service_task_environments: fail closed when checkAnySessionRunning
  errors so a transient DB hiccup can't bypass the running-session
  guard and tear down a live container/sandbox; teardown is now truly
  best-effort with errors.Join across container/sandbox/worktree, so a
  failed Docker call doesn't permanently orphan the worktree on retry.
  Adds tests for the running-check error path and the multi-resource
  best-effort path.
- executor_execute: propagate GetTaskEnvironmentByTaskID errors instead
  of silently launching a fresh environment that orphans existing
  containers/worktrees; gate environment reuse on matching ExecutorType
  so a profile switch doesn't pass stale PreviousExecutionID/container
  IDs to the wrong backend.
- task_environment_handlers: reject malformed JSON on POST
  /tasks/:id/environment/reset (still allow empty body) so a destructive
  endpoint doesn't silently proceed with a bad payload.
- worktree (PushEnvironmentBranch): bound git push with a 30s context
  timeout and disable interactive prompts via GIT_TERMINAL_PROMPT=0
  so a stalled remote can't hang the request thread.
- manager_environment: explicitly call ensureClient() before
  ContainerMgr() in DestroyContainer/GetContainerLiveStatus so a missing
  Docker daemon surfaces as "docker unavailable" rather than the
  generic "container manager not initialized".
- container: WorkingDir always uses the in-container /workspace path
  rather than the host workspace path (Docker would happily start in an
  unrelated directory in host bind-mount mode); bootstrap now logs the
  prepare-script exit code so failures show up in container logs.
- docker/client: CreateContainerInteractive honors ContainerConfig.Entrypoint
  so the same config struct produces consistent behavior on both creation
  paths.

Frontend:
- executor-settings-button: extract useTaskEnvironment hook (component
  shrinks below the 100-line limit); only treat 404 as empty env (other
  errors no longer wipe a valid environment); track "loaded once" via a
  ref so loadEnv doesn't depend on env state — that dep was creating a
  new function identity on every successful poll, cancelling the
  3-second interval and turning the popover into a tight loop hammering
  /environment/live; copy-shell command uses sh instead of bash so it
  works on user-built minimal images.
- task-reset-env-confirm-dialog: AlertDialogAction onClick calls
  e.preventDefault() so Radix's auto-close doesn't dismiss the dialog
  before the async reset completes (loading spinner now visible; close
  is parent-controlled via onOpenChange).
- remote-credentials-card: track env method in selectedIds the same way
  files/gh_cli_token are tracked, and let initialChoice match either
  selectedIds OR envSecretId. First-time env-credential setup for an
  agent that exposes both files+env was broken: clicking the env radio
  never updated state, choice re-derived to "none", and the secret
  picker disappeared before the user could pick anything.
- e2e/executor-reuse: select the acknowledge checkbox by label, not by
  positional last(), so adding another checkbox to the dialog can't
  silently target the wrong control.

Format pass on prettier-flagged files; gofmt fixed two whitespace
nits in lifecycle files.

* test(executor): cover applyExistingEnvironment executor-type mismatch guard

* fix(docker): connect agentctl through published ports

* fix(web): keep remote auth selection stable

* fix(docker): reuse task containers across sessions

* fix(docker): resume task environment after refresh

* fix(docker): track executor containers for cleanup

* fix(docker): explain missing agent credentials

* fix(docker): surface container launch progress

* fix(docker): stabilize container reuse and feedback

* fix(docker): show task titles in container lists

* fix(docker): use container icon for docker tasks

* fix(orchestrator): align environment reuse after rebase

* fix(sprites): reconnect sessions on existing sandboxes

* fix(docker): use container icon for local docker executor too

Local docker tasks were rendering the Docker brand "chip" mark while
remote docker uses the container glyph. Map both variants to
IconContainer so the executor surfaces look consistent.

* feat(sprites): provision a fresh sandbox when the persisted one is gone

Resuming a Sprites task whose sandbox was destroyed upstream returned
"sprite not found" and dead-ended the user. Detect the upstream 404 via
a typed ErrSpriteNotFound sentinel, mark the reconnect step skipped with
a user-facing warning explaining what happened, swap the prepare plan
in place to the fresh-provision shape, and continue the launch under a
new sandbox name on the same branch.

The progress plan is now mutable so the reporter can stream the fresh
steps live after the reconnect-then-fresh transition. Only the explicit
not-found case triggers fallback; other reconnect failures still
surface as before, and the fallback fires at most once per launch.

* feat(web): friendlier header for sandbox-recovered prepare runs

When the only warning on a completed prepare is the missing-sandbox
fallback notice, render "Environment prepared on a fresh sandbox" in
the header instead of the generic "Environment prepared with warnings".
The fallback row still carries its own amber warning callout so the
user understands why a fresh sandbox was provisioned.

* feat(web): unify executor surfaces in the right-side popover

The desktop task top bar carried a small cloud icon (RemoteCloudTooltip)
in the breadcrumb cluster while the right-side Executor popover showed
the same kind of info — two surfaces for one concept. Remove the cloud
icon from the desktop top bar; kanban cards, task list rows, and the
mobile top bar keep theirs since they have no popover.

The popover button now picks its icon by executor type (cloud for
sprites, container for docker, server for local PC) using a shared
getExecutorStatusIcon helper. While the prepare progress is preparing
the icon swaps to a spinner so the user can see at a glance that work
is in flight.

The popover content gains a Preparing-environment section that streams
"Step N of M: <step>", an amber inline note when the missing-sandbox
fallback is in progress, an Environment-ready row with duration on
success, and a failed-step summary on failure. Polling promotes to the
active 3s cadence while preparing so the icon flips to ready without
the user opening the popover.

* fix(docker): swap container glyph for a plain box icon

IconContainer's vertical ribbing reads as a microchip on small sizes.
IconBox is an unambiguous outlined cube. Use it for both docker
variants so the executor surfaces look like a box, not a chip.

* fix(orchestrator): preserve base branch on resume for clone-based executors

Sprites and Docker resumes were sending the launch request with an empty
BaseBranch, so the in-sandbox prepare script's `git clone --branch <X>`
shifted positional args and failed with "repository '/workspace' does
not exist". applyResumeRepoConfig only set BaseBranch inside the
worktree-specific block; widen that to all executors that require a
clone URL.

Local executors are intentionally left untouched: LocalPreparer reads
BaseBranch and forces a `git fetch && git checkout` against the user's
own workspace, which would clobber the "use current state" path.

* feat(sprites,docker): generate a kandev feature branch for clone-based executors

Sprites tasks committed straight onto the chosen base branch because
SpritesPreparer didn't populate WorktreeBranch and the orchestrator's
"forward env.WorktreeBranch on reuse" check only fired for Docker.

Mirror the Docker preparer's TaskBranchName generation in Sprites,
extract a shared `nonWorktreeTaskBranch` helper that uses a
deterministic suffix derived from the task ID (so legacy env rows that
predate this change resolve to the same branch on every resume), and
let the orchestrator forward env.WorktreeBranch for any clone-based
executor — Docker or Sprites.

* fix(sprites,docker): prefer existing remote feature branch on resume

When a sandbox is destroyed and we provision a fresh one for the same
task, the agent may have previously committed and pushed onto the
feature branch. The previous prepare scripts only ran `git checkout -b
{{worktree.branch}}` which would overwrite that progress with a fresh
branch off the chosen base.

Try `git fetch origin <feature>` first; on success, `git checkout -B`
against the remote tip so the agent picks up where it left off. Falls
back to a fresh local branch when origin has nothing.

Also add the same checkout block to the Sprites prepare script — it was
missing entirely, so every Sprites session was committing onto main.

* test(sprites): lock in informative branch name in fallback warning

Now that BaseBranch is preserved on resume for clone-based executors,
the missing-sandbox fallback warning detail names the actual branch
("Cloning … on the same branch main") instead of the "(unknown)"
placeholder. Add a regression test so a future change to the resume
metadata propagation can't silently regress that copy.

* fix(sprites): preserve sandbox on plain agent stop so resume is fast

StopInstance was calling sprite.Destroy() unconditionally, so every
session stop tore down the cloud sandbox. Resume then always fell into
the missing-sandbox fallback, re-cloned the repo, re-ran the prepare
script, and lost the agent's working tree. That's a 30s+ round-trip
for what should be "stop the process, then re-attach later".

Limit Destroy to terminal lifecycle reasons (task/session
deleted/archived), the same set runTerminalCleanupScript already uses.
Plain stops just close the proxy and leave the sandbox alive.

Also update the existing Docker e2e regex: the feature-branch suffix is
now a deterministic 6-hex-char prefix of the task UUID rather than a
random 3-char tail, so resume on legacy env rows lands on the same
branch.

* test(docker): cover stop + resume → same container, branch preserved

Adds a Docker e2e test that exercises the user-visible stop-then-resume
flow: launch session, stop it via WS session.stop, launch a fresh
session on the same task, and assert the same task environment +
container ID are reused and the kandev feature branch is preserved.

Also nudge the executor popover padding: when the prepare-status
section is rendered above the Sprites/Docker info block, the cumulative
top+bottom paddings looked too generous around "Sprites sandbox" /
"Local Docker". Tighten both sections vertically without affecting the
horizontal rhythm or the bottom action row.

* feat(web): show "Resuming session" feedback in popover during reconnect

The bottom Resume button on the chat panel was leaving users staring at
nothing for the 5-30s reconnect window: prepare-progress events are
intentionally suppressed on resume (the workspace is already there), so
the popover sat in the idle phase even while session.state was STARTING.

Synthesize a "resuming" phase from session state when no prepare events
are flowing, rendered as a spinner row in the popover with copy
"Resuming session — Reconnecting to the existing environment…". The
button icon also flips to the spinner so the toolbar advertises the
in-flight work without the user having to open the popover.

Extract the prepare-status reducer + section component into their own
file so the main popover module stays under the 600-line ESLint cap.

* fix(codex): align with the standard SessionDirTemplate pattern

Codex was the only agent that bind-mounted its host home into the
container via an explicit `Mounts: [{home}/.codex → /root/.codex]`
entry, while every other ACP agent uses `SessionConfig.SessionDirTemplate`
+ `SessionDirTarget`. Move codex onto the same surface so the
host-state isolation work in the next commit covers it uniformly.

This change alone does not stop the host-state leak — the bind mount
still resolves to the host's ~/.codex for now — but it removes the
last agent-specific branch in the runtime config so the docker
container manager can apply one rule for all agents.

* fix(docker): isolate per-container agent state from the host home

Every ACP agent's session dir was bind-mounted from ~/.<agent> on the
host into /root/.<agent> in the container. That exposed host state DBs,
session caches, and lock files to the container — codex's rollout DB
caches absolute /Users/... paths, which don't resolve inside Linux and
the agent silently loses prior thread context on resume.

Replace the host bind mount with a kandev-managed per-container session
dir under <kandev-home>/agent-sessions/<instance_id>/. Before the
container starts, selectively copy the auth-relevant files (the same
files Sprites already copies via RemoteAuth.SourceFiles) into that
dir; everything else — state DBs, sessions/, lock files — stays on the
host and the agent rebuilds them inside the container with
container-local Linux paths.

The change is agent-agnostic: any agent that has SessionDirTemplate
+ SessionDirTarget gets the new behavior automatically. Cleanup runs
on destructive stops only (task/session deleted/archived), mirroring
the Sprites preserve-on-stop rule so plain stops let resume re-attach
to the same agent state.

* fix(executor): make the kandev feature-branch checkout an invariant postlude

Profiles created in the UI snapshot the *then-current* DefaultPrepareScript
into their stored prepare_script field. Updates to the default — like
shipping the worktree-branch checkout block — never reach those older
profiles, so users keep landing the agent on the chosen base branch
(main) even though the env row carries a feature branch name.

Append a kandev-managed postlude to whatever prepare_script ran (custom
or default), wrapped in a subshell + `|| true` so it's idempotent and
never blocks agentctl from starting. The postlude prefers the remote tip
when origin already has the branch (resume after a destroyed sandbox),
falls back to checking out the existing local branch, and finally
creates one off the current HEAD.

Sprites needed the WorktreeProvider in resolvePrepareScript so the
postlude's `{{worktree.branch}}` actually expands; Docker already had
it.

The Docker e2e gains a regression case that explicitly stores an older
default-style script (clone, no checkout) and asserts the agent still
ends up on the feature branch — locking the contract in regardless of
which default the user happens to have stored.

* fix(web): tighten executor popover padding under the env header

The Sprites/Docker info block under the Environment-ready row had too
much vertical breathing room — the header-to-fields gap and the
inter-row spacing piled up and the popover felt airy. Trim the section
to match the snug rhythm of the rest of the popover (px-3 py-2,
1.5-unit gap before the field list, 1-unit gap between rows). Also
unify the Environment-ready row from p-3 to px-3 py-2 so the dividers
between sections sit at the same vertical cadence.

* fix(web): tighten executor popover bottom padding too

The previous tighten only fixed the top half — pb under the field rows
plus the Reset-row's full p-2 still left a generous gap below the last
"Sprite ..." line. Same fix applies whether the popover is showing
Docker or Sprites.

EnvironmentInfo: pt-2 pb-1.5 (was py-2) so the bottom field hugs the
divider, and the Reset-row goes from p-2 to px-2 py-1.5 so the button
sits closer to the text above.

* fix(web): drop the redundant Environment-ready row + tighten popover top

The READY badge next to the executor name already conveys ready-state;
the dedicated "Environment ready · 12s" row above the executor info
just added vertical noise. Skip rendering for the ready phase (other
phases that surface non-redundant info — preparing, resuming, failed —
still render). Drop top padding on the executor info section now that
nothing sits above it on the happy path.

* fix(web): move executor info to the left top bar, hover-to-open, no border

The executor section is informational, not a button — readiness state +
sandbox/container/branch fields. Move it to the left top bar (next to
the integrations menu) where it sits alongside the other contextual
info, drop the outline-button styling, and switch from click-to-open to
hover-to-open via HoverCard with 150ms open / 250ms close so the cursor
can travel to the destructive Reset button without flickering.

The trigger is a borderless icon-with-status-dot span; tabIndex keeps
it keyboard-accessible. Reset still works — it opens the existing
confirmation dialog independently of the hover card.

Tests: pointerEnter instead of click for the hover-to-open path.

* refactor(cleanup): drop dead code, dedupe predicates, unify token injection

Tier 1 of the post-review cleanup. Each item is independently safe; bundling
because they're all small, mechanical, and lint-passing.

- fix(clone): unify injectTokenIntoURL across Docker and Sprites — Sprites
  used to ignore GH_TOKEN entirely (a user with only GH_TOKEN set got 401
  on Sprites clone but worked on Docker). Promote one
  `injectGitHubTokenIntoCloneURL` helper, accept both env vars
  (GITHUB_TOKEN wins, matching Docker), and use the documented
  `https://x-access-token:T@github.com/` form on both surfaces.
- fix(executor-popover): swap the <span tabIndex role="button"> trigger
  for <Button variant="ghost"> so Enter/Space activation, focus rings on
  Safari, and disabled semantics come for free.
- test(docker): rewrite TestDockerStopInstance_PreservesSessionDirOnPlainStop
  to drive the real DockerExecutor.StopInstance path with ContainerID=""
  (skips the docker daemon, exercises the kandev-side cleanup branch).
  Previous version only iterated shouldRunExecutorCleanup strings — would
  pass even if StopInstance always cleaned up. Now table-driven over
  preserve+destructive reasons with on-disk asserts.
- refactor(orchestrator): drop dead `_ = arg` parameters from persist*
  helpers (persistResumeState, persistModelSwitchState, persistLaunchState).
  Brings signatures from 9→6 args, under the funlen lint cap.
- refactor(orchestrator): collapse byte-identical isContainerizedExecutor
  ≡ isCloneBasedExecutorType predicate into one.
- refactor(lifecycle): drop dead getMetadataBool, FileUploader.RunCommand,
  expandMountSource {home} arm (no production agent ships {home} in
  Mounts; codex_acp_test asserts it), dockerTaskBranch one-line wrapper.

* refactor(scripts): postlude owns worktree-branch checkout

Strip the inline `git checkout -B` block from the Docker and Sprites
default prepare scripts. The kandev-managed postlude
(KandevBranchCheckoutPostlude) is appended unconditionally after every
user prepare script, so keeping the same logic inline made the default
and the postlude two sources of truth that could drift.

* refactor(lifecycle): drop dead instance_port persistence entry

`instance_port` is only used as a zap log field — it's never written to
the executor metadata map, so carrying it across resume in
`persistentMetadataKeys` was a no-op.

* refactor(lifecycle): drop legacy {home} session-dir fallback

ExpandSessionDir kept a `{home}/<dotdir>` fallback for the case where
kandevHomeDir was unresolved. In production, ResolvedHomeDir() always
returns a non-empty path, so the fallback only ever fired in tests
that hadn't been updated to pass an instance ID. Returning the empty
string in that case makes container.go skip the bind mount — the
correct behaviour, since without a kandev home there's no session dir
to mount.

* refactor(orchestrator): collapse env-reuse helpers into reuseExistingEnvironment

The two env-reuse helpers (`applyExistingEnvironment` and
`applyExistingEnvironmentRuntimeMetadata`) were always called together
and shared the same intent: carry the previous TaskEnvironment forward
into the launch request. Merge them into a single
`reuseExistingEnvironment(ctx, req, env)` so the executor-type gate
runs once for both env-level fields and runtime metadata, and callers
have a single entry point.

Document the layered metadata rule (running wins for container_id;
env wins for sprite_name and other stable IDs) inline so the next
reader doesn't have to reverse-engineer it from two functions.

* refactor(web): lift prepare-summary reducer + isFallbackNoticeStep into lib/prepare

Move the popover's prepare-summary reducer (and the
`isFallbackNoticeStep` helper duplicated word-for-word in
`prepare-progress.tsx`) into `apps/web/lib/prepare/summarize.ts`. The
hook moves to `hooks/domains/session/use-prepare-summary.ts` to match
the project's domain-organized hook layout.

The popover's PrepareSummary type and the chat panel's EffectiveStatus
serve different UIs, so the panel keeps its own `deriveStatus` — only
the literal duplicate (`isFallbackNoticeStep`) is shared.

* refactor(web): split executor-settings-button by concern

Slice the 471-line button file into three modules with single
concerns: the popover shell stays in `executor-settings-button.tsx`
(168 lines, just trigger + popover layout + icon/status dot),
`executor-environment-info.tsx` owns the env detail rows + status
badge, and `hooks/domains/session/use-task-environment.ts` owns the
fetch/poll/reset lifecycle alongside other session hooks.

* refactor(e2e): extract docker + session helpers from docker-launch.spec

Pull the inline docker CLI helpers (`dockerInspectExists`, `dockerStop`,
`dockerCurrentBranch`, etc.) and the generic session-state pollers
(`waitForLatestSessionDone`, `waitForSessionDone`,
`waitForSessionEnvironment`) into `e2e/helpers/docker.ts` and
`e2e/helpers/session.ts`. The spec drops from 763 to 647 lines and the
helpers can be reused by future docker / session tests.

* test(scripts): execute postlude under bash -e against a real git repo

Pair the static-content assertion with a behaviour test that runs the
postlude as a real shell script. Three scenarios exercise the three
branches inside the snippet: remote tip exists (track origin), no
remote (fresh local branch), and already on the target branch
(idempotency). This catches regressions where the snippet still parses
as bash but no longer does the right thing — wrong refspec, missing
-B, swallowed exit code, etc.

* test(sprites): integration check for stop preserve + resume reconnect path

Each piece of the resume-fast path is unit-tested separately
(StopInstance keeps the token cache, spritesShouldReconnect reads
sprite_name, the reconnect plan has 3 steps). This new test wires them
together end to end so a future change that accidentally strips
sprite_name on stop or forgets to look at metadata on launch fails
loudly here, not at runtime.

* test(sprites): assert plan ordering instead of absolute indexes

Replace `plan.index(spriteStepWaitHealthy) == 1` with an
`assertPlanOrder` helper that checks "X runs before Y". The contract
the test guards is the order of create-sprite → wait-healthy →
agent-instance, not their absolute slot numbers — adding a new step
shouldn't break the test if the order is preserved.

* test(web): hoist test constants above vi.mock factories

Move SESSION_ID and friends above the vi.mock factories so the mock
closures don't depend on vitest hoisting magic capturing TDZ-undefined
locals. The previous layout worked because factories aren't invoked
until the module under test is imported (after const initialization),
but a refactor that imports earlier would break the closure silently.

* fix(seeder): contain localFileUploader writes inside the session root

CodeQL flagged go/path-injection on the localFileUploader.WriteFile
sink: instanceID flows into the write path, and a malformed value
would let writes escape the kandev session root and clobber arbitrary
host files. Bind the uploader to its session root and reject any path
that filepath.Rel resolves to a "../" escape. Production callers
always pass an internally generated instanceID; the check is defense
in depth against a future caller plumbing in user input.

* refactor(docker): split reconnectToContainer + cover reconnect helpers with unit tests

Address recurring review feedback on the docker reconnect path:

- reconnectToContainer was 98 lines (over the 80-line repo limit). Split
  into ensureContainerRunning (inspect + start + re-inspect) and
  bringupAgentctl (health-check, find-or-create instance, transparent
  re-handshake). The orchestration body now reads top-down at 49 lines.
- resolveDockerEndpoint and waitForAgentctlHealth grew real branches in
  this PR (host-port lookup with container-IP fallback; 240×500ms
  retry loop) but had no direct tests. Introduce narrow hostPortLookup
  / healthChecker interfaces, satisfied by *docker.Client and
  *agentctl.ControlClient, so both functions are now driven by stubs
  that exercise happy-path, fallback, retry-until-healthy, exhaustion,
  and context-cancellation branches.
- Add direct unit tests for the docker package's pure helpers
  (normalizeDockerHostIP, buildDockerPortBindings) and for
  dockerAgentctlPortBindings — a regression in the published port
  range or host IP would silently break container reconnect.

* test(handlers): cover ensureShellExecution stale-execution recovery

The recovery branch (Failed execution → CleanupStaleExecutionBySessionID
→ relaunch) is the load-bearing piece of shell session resilience and
had no direct test. Extract the body into a free function that takes a
narrow shellLifecycle interface so the four branches are testable
without standing up a real lifecycle.Manager:
  - happy path (running execution, no cleanup)
  - stale recovery (Failed → cleanup → fresh)
  - initial GetOrEnsureExecution failure
  - cleanup failure must NOT trigger a re-launch loop
  - relaunch failure surfaces a clear "recover execution" error

* fix(backend): clear lint blockers from CI lint job

CI's only-new-issues lint surfaced three real items left over from
recent additions:

- container.go bootstrap snippet had two `fi` shell tokens flagged by
  dupword. Tagged with the same nolint directive used by the
  KandevBranchCheckoutPostlude (two distinct shell blocks closing).
- DockerExecutor.CreateInstance was 89 lines (over funlen 80). Split
  the body into helpers: reportCreateInstanceProgress (deferred
  progress reporter), tryReconnect (gates the resume path),
  seedSessionDir (per-container auth seed), buildContainerLaunchConfig,
  and buildCreatedInstance. CreateInstance is now a 30-line top-down
  orchestrator.
- executor_execute.go was 887 effective lines (over revive 800). Move
  reuseExistingEnvironment + its helpers (latestExecutorRunningForEnv,
  executorRunningMatchesEnvironment, applyExecutorRunningMetadata,
  ensureLaunchMetadata) into a sibling executor_environment_reuse.go.

* fix(web): only block submit during real env prepare, not every STARTING

The "agent still being set up" submit gate was triggering on every
session that briefly transitions through STARTING — including local
quick-chat, which has no prepare phase and has its agent live within
~100ms. The eager-init quick-chat e2e tests (chat/quick-chat.spec.ts
and chat/message-queue.spec.ts) wait for the editor to become
editable, then press Cmd+Enter. The race made wrappedSubmit a no-op
exactly when the test was firing — message dropped, agent never saw
it, downstream assertions failed.

Split the merged isStarting flag in useSessionState: keep the broad
flag (still used for the workspace-prep loading UI), and expose
isPreparingEnvironment as a separate signal that's only true while
prepareProgress.status === "preparing". Thread it through chat-input-
area → chat-input-container → useChatInputContainer and gate the
submit-disabled tooltip on it instead of the broad isStarting.

Net: Docker / Sprites bootstrap still surfaces the "agent still being
set up" message and disables submit; quick-chat / local sessions stop
silently dropping in-flight messages.

* fix(web): keep editor disabled during STARTING so quick-chat sends don't race

My previous fix made submit no longer block on isStarting, with the
goal of keeping local quick-chat sessions from silently dropping
messages. The trace from CI showed it traded one failure for another:
the editor became contenteditable while the session was still
STARTING, the e2e suite pressed Cmd+Enter immediately, and the
backend rejected with "Failed to send message to agent" because the
agent process wasn't ready yet.

Restore the original semantics — STARTING blocks both editor and
submit — but keep `isPreparingEnvironment` as the narrower trigger for
the "agent still being set up" tooltip. Net behaviour:
  - STARTING transition: editor disabled, no tooltip (signal is the
    disabled state itself)
  - container/sandbox prepare: editor disabled + tooltip explaining why
  - quick-chat sessions: editor stays disabled until RUNNING, no race

* docs(web): correct isPreparingEnvironment JSDoc after STARTING re-block

The 9389ac9 fix restored isStarting as the editor disabled gate.
Update the JSDoc on isPreparingEnvironment to match: it now drives
the "agent still being set up" submit-disabled tooltip only — the
disabled state itself is gated on the broader isStarting so e2e
Cmd+Enter doesn't race the not-yet-ready agent.

* fix(lifecycle): make agentctl health retry delay cancelable

Replace `time.Sleep(retryDelay)` in `waitForHealth` (container.go) and
`waitForAgentctlHealthWith` (executor_docker.go) with a cancelable
`select { <-ctx.Done() | <-time.After(retryDelay) }`. Also skip the
final retry's sleep — the loop only re-enters when `i+1 < maxRetries`,
so the extra delay was just added latency on aborted launches.

Cancellation now propagates within ≤500 ms instead of waiting for the
next probe iteration to observe `ctx.Err()`.

* fix(scripts): non-destructive postlude branch checkout

The previous postlude tried `git fetch + git checkout -B feature
origin/feature` first, which is force-reset semantics: any unpushed
local commits on the resumed workspace would be orphaned without
warning.

Reorder to prefer the existing local branch (preserving in-flight
work after a sandbox/container resume), fall through to a fresh
tracking branch off origin, and only as a last resort create the
branch off HEAD:

  1. local feature branch exists  → checkout (preserves)
  2. else fetch + checkout -b      → tracks origin, no force-reset
  3. else                          → create new local branch

Behaviour tests still pass: fresh sandbox with remote tip → falls
through to step 2 (clean track). The static-content test now also
verifies the destructive `-B "feature" "origin/feature"` form is
absent.

* fix(lifecycle): reject non-regular files for KANDEV_MOCK_AGENT_LINUX_BINARY

`os.Stat` succeeds on a directory, so the previous resolver happily
accepted a directory path and forwarded it to the Docker bind-mount +
exec path, where it surfaced as a confusing "permission denied" /
"exec format error" deep in container bootstrap.

Validate `info.Mode().IsRegular()` up front and return a clear error
naming the env var.

* docs(test): update stale comment after retry-delay became cancelable

aaec9e87 swapped the retry sleep for a select on ctx.Done. The test
comment in TestWaitForAgentctlHealth_HonoursContextCancellation still
described the old "subsequent time.Sleep is short" reasoning, which is
no longer how cancellation propagates — the select exits immediately
via ctx.Done() within the same iteration.

* fix(web): extract buildDialog{FormBody,Footer}Props to satisfy lint cap

After the main merge, prettier expanded my hand-compacted destructuring
in TaskCreateDialog onto multiple lines, putting the function at 115
lines (over the 100 cap). Extract the two prop-assembly blocks into
buildDialogFormBodyProps + buildDialogFooterProps helper functions so
the orchestrator becomes a thin JSX wrapper. No behaviour change — the
helpers project the same shape, 1:1.

Also fixes the upstream CI failure: prettier --check was rejecting the
hand-compacted destructuring lines.

* test(executor): cover repositoryCloneURL LocalPath fallback

The function gained a second code path to support Docker E2E tests with
file:// remotes — when ProviderOwner/ProviderName are both empty and
LocalPath is set, it shells out to git remote get-url origin. The
original table-driven test had eight cases, none of which exercised
the new branch.

Add a dedicated TestRepositoryCloneURL_LocalPathFallback with four
sub-cases:
  - local-only repo with a valid origin remote → returns the URL
  - local-only repo without an origin remote → returns empty
  - local-only non-git directory → returns empty
  - no provider and no local path → returns empty (regression for the
    early-return branch the old table already covered)

* refactor(web): move task-create-dialog prop builders to sibling file

3a0c45a0 introduced buildDialogFormBodyProps + buildDialogFooterProps
to slim TaskCreateDialog under the function-line cap, but adding them
in-file pushed task-create-dialog.tsx to 612 raw lines. Local eslint
still passes (skipBlankLines + skipComments brings the effective count
under 600), but Claude's review flagged the raw-line growth as a
blocker — and the prop builders are pure projections from setup state
to plain JS objects, no JSX, so they belong outside the component
module anyway.

Move computeHasAllBranches and both builders to a new
task-create-dialog-prop-builders.ts. Export TaskCreateDialogProps and
useTaskCreateDialogSetup from dialog.tsx so the builders can derive
their parameter types via ReturnType<typeof useTaskCreateDialogSetup>.

task-create-dialog.tsx: 612 → 522 raw lines.

* chore(web): apply prettier to task-create-dialog-prop-builders.ts

The new file from 08aa04c5 wasn't run through prettier before push,
so CI's --check rejected the long import line. Mechanical reformat
(import collapsed to one line, no semantic change).

* fix(web): import-type the prop-builders circular dep + add computeHasAllBranches test

Two findings from Claude on 08aa04c5 + fa5ba9d6:

  - The mutual import between task-create-dialog-prop-builders.ts and
    task-create-dialog.tsx is purely type-level (TaskCreateDialogProps
    is an interface; useTaskCreateDialogSetup is consumed only through
    `ReturnType<typeof ...>`). Switch to `import type` so bundlers and
    analysis tools don't flag this as a runtime cycle.

  - computeHasAllBranches was a private function in
    task-create-dialog.tsx and didn't need its own test. Moving it to
    a sibling .ts utility makes it a public helper, and the SKILL.md
    convention is that public utilities carry their own tests. Add a
    table-driven unit test covering the three branches: noRepository
    short-circuit, useGitHubUrl branch validation, and the
    every-repo-has-a-branch case.

* fix(web): pass executorType to kanban card RemoteCloudTooltip

The kanban-card call site was missing the optional executorType prop,
so getExecutorStatusIcon(undefined, ...) fell through to the default
arm and rendered IconServer for every remote-executor task —
including Docker, which should be IconBox. Other call sites
(task-item.tsx, session-mobile-top-bar.tsx) already pass it.

This was a pre-existing bug from c64e8356 that became visible because
this PR makes Docker tasks first-class on the home view.

* fix(web): give executor popover env header breathing room above the title

The EnvironmentInfo block was `px-3 pb-1.5` (no top padding), so the
"Local Docker" heading hugged the popover top edge when no prepare-
status row was rendered. Add `pt-2.5` to match the visual rhythm of
the prepare-status row that sometimes sits above it.

* fix(sprites): DestroySandbox passes terminal StopReason so cleanup actually runs

shouldRunExecutorCleanup gates Sprites teardown on a small set of
terminal reasons (task/session archived/deleted). DestroySandbox was
calling StopInstance with an empty StopReason, which never matched
that set — so the sandbox was never actually destroyed, only the
local execution state was cleaned up. The sprite kept running
upstream until it idled out.

Pass StopReasonTaskDeleted explicitly. While here, lift the four
terminal-reason literals into named constants so callers don't have
to keep them in sync with shouldRunExecutorCleanup's switch.

* fix(web): block submit when selected agent is incompatible with executor

useExecutorProfileCompat returned noCompatibleAgent=true only when
NO agent profile was compatible with the chosen executor. The user
could still pick an agent that happened to be incompatible with the
currently-selected executor (e.g. by switching executor after the
agent was chosen) and the submit gate didn't fire.

Two fixes:
  - Hook now takes the selected agent profile ID and returns
    noCompatibleAgent=true when the *selected* agent isn't in the
    compatible set (covers both the old "no agents at all" case and
    the new "wrong agent picked" case).
  - Footer's session-mode disable was gating only on missing agent;
    add noCompatibleAgent so the disable matches the reason text
    sessionDefaultReason already surfaces.

* fix(task-env): sanitize generic 500 response on reset failure

The reset-environment endpoint was returning the raw err.Error() in the
500 response body, leaking internal details (paths, wrapped chains,
infra error strings). Keep the structured log at error level for
operators and return a generic "failed to reset task environment"
to clients.

* fix(worktree): push to the branch's configured upstream remote, not hard-coded origin

PushEnvironmentBranch was running `git push origin <branch>`, which
breaks for repos whose primary remote isn't called "origin" (fork
workflows that use "upstream", "github", etc.). Read the branch's
configured upstream remote via `git config branch.<branch>.remote`
and fall back to "origin" only when no upstream is set.

* refactor(web): type workflows/snapshots in DialogFormBodyProps properly

DialogFormBodyProps had `workflows: unknown[]` and `snapshots: unknown`
which dropped type safety and forced unsafe casts at the call site.
Replace with the actual store-derived types (WorkflowsState["items"]
and KanbanMultiState["snapshots"]) that the call sites already
produce.

* fix(turns): align info.TaskEnvironmentID with the resolved env after fallback

GetWorkspaceInfoForSession initializes info.TaskEnvironmentID from
session.TaskEnvironmentID, then resolves the env — first by that ID,
falling back to GetTaskEnvironmentByTaskID when the session's ID
points to a stale/missing row. When the fallback kicks in, the
resolved env has a different ID, but applyTaskEnvironmentToWorkspaceInfo
was only updating info.TaskEnvironmentID when it was empty. So
downstream reconcilers received metadata + path from one env keyed by
a different env's ID.

Always overwrite info.TaskEnvironmentID with env.ID inside the apply
function — once we've picked an env to spread, the info should
identify that env consistently.

* fix(web): gate compatibility on effectiveAgentProfileId, not the raw form value

The previous fix passed fs.agentProfileId to useExecutorProfileCompat,
so workflow-locked sessions (where fs.agentProfileId is empty and the
real value comes from workflowAgentProfileId) bypassed the
noCompatibleAgent check entirely — selectedAgentProfileId was "" so
the new "selected agent isn't in compatible set" branch silently
returned false.

Thread `effectiveAgentProfileId` (which already coalesces the form
value with the workflow override) through to the compat hook so
locked agent selections are gated the same way as user-picked ones.

* test: cover detectBranchRemote + applyTaskEnvironmentToWorkspaceInfo stale-ID fallback

Two test additions for the Round 29/30 Claude blockers — both are
recent backend fixes (a07b8f1f, e6195f3f) that landed without
regression coverage.

  - worktree_test.go: TestDetectBranchRemote covers the three
    branches of the new helper — configured upstream returned,
    no upstream → "origin" fallback, non-git dir → "origin"
    fallback. GIT_CONFIG_NOSYSTEM=1 keeps host config from
    interfering.
  - service_turns_test.go: a regression test that drives
    GetWorkspaceInfoForSession down the fallback path
    (session.TaskEnvironmentID points at a missing row; real env
    exists under a different ID for the same task) and asserts
    info.TaskEnvironmentID is aligned with the resolved env, plus
    WorkspacePath/container_id come from the same env.

* fix(backend): extract defaultGitRemote constant to satisfy goconst

The new detectBranchRemote helper plus its tests pushed "origin"
literal occurrences past goconst's min-occurrences=3 threshold.

* fix(backend): inject git identity in worktree tests for CI

CI runners have no user.email/user.name configured, so
`git commit --allow-empty` aborts with "Author identity unknown".
GIT_AUTHOR_*/GIT_COMMITTER_* env vars override the config check.

* fix(backend): base mustGit env on os.Environ to keep HOME/PATH

Appending to a nil cmd.Env produced a single-element slice that
replaced the inherited environment, leaving the git subprocess
without HOME, PATH, etc. Base on os.Environ() instead.
2026-05-14 21:26:09 +01:00
Luan Martins 1359811187 feat(mobile): replace merged-diff view with timeline + overlay sheets on Changes tab (#902) 2026-05-14 10:27:38 +01:00
Carlos Florêncio e3ea4e1766 fix(agent): prevent duplicate execution of repository setup scripts (#898) 2026-05-13 19:23:24 +01:00
Carlos Florêncio d7e183847c fix(web): hide stuck Resume session button after click + cover with e2e (#890) 2026-05-13 18:39:59 +01:00
Carlos Florêncio 08f782af22 fix(agentctl): drop -l from task shells so PATH keeps agent CLI bin dir (#889) 2026-05-13 18:39:39 +01:00
Carlos Florêncio 7011bbcbc4 fix: session tab leak, PR icon crash, and PR review prompt scope (#897) 2026-05-13 18:39:01 +01:00
Carlos Florêncio d48ee80472 fix(e2e): release agent ports during E2E reset to prevent shard exhaustion (#888) 2026-05-12 22:41:21 +01:00
Carlos Florêncio 18ce6e13a3 fix(improve-kandev): lock workflow, block submit during bootstrap, hide None mode (#885) 2026-05-12 18:54:14 +01:00
João Salavisa 45a46f9899 feat: support tasks without a repository (#850)
* feat: support tasks without a repository

Lets users create a task with no repository attached — useful for research,
brainstorming, design discussions, or pointing the agent at an existing
non-git folder. The create-task dialog gains a 3-mode segmented control
(Repo / URL / None); "None" mode replaces the chip row with a folder
picker that browses the host filesystem via a new GET /api/v1/fs/list-dir
endpoint and either commits an absolute path or falls back to a kandev-
managed scratch workspace at <homeDir>/tasks/<workspaceID>/<taskID>/.

The worktree executor is hidden from the picker in no-repo mode (it
inherently needs a repo); the lifecycle manager also short-circuits
worktree mode server-side as a belt-and-suspenders guard. The Changes
panel auto-closes for repo-less tasks since it has nothing to show, and
the cumulative-diff handler returns null instead of erroring when no
base commit exists.

Spec + plan added at docs/specs/tasks-without-repositories/.

* fix(backend): anchor folder picker to discovery roots (CodeQL path traversal)

CodeQL flagged the new GET /api/v1/fs/list-dir handler for piping a
user-controlled path straight into os.Stat / os.ReadDir. Restrict the
listing to paths inside the configured discovery roots (defaults to
$HOME, same allowlist as repository discovery), reject anything outside
with ErrPathNotAllowed → HTTP 403, and clip the breadcrumb's "up"
button at the root so the picker can't be used to walk above the
allowed prefix.

Tests cover: rejection of paths outside roots, traversal escapes (../),
default-to-first-root, and parent exposure for nested paths.

* fix(backend): use os.Root for folder picker (recognized CodeQL sanitizer)

Previous attempt anchored to discovery roots via isPathAllowed, but
CodeQL's data flow doesn't model that helper as a sanitizer and kept
flagging os.Stat / os.ReadDir. Switch to os.Root (Go 1.24+): open the
matching discovery root once and run Stat/Open/ReadDir against the
relative path within it. The OS itself enforces containment — symlinks
and traversal cannot escape — and CodeQL recognizes os.Root as safe.

* fix: address PR review feedback

- Changes panel auto-close now distinguishes "task loading" from
  "task has no repos" (returns boolean | null), so the panel isn't
  permanently removed during the first-render window before task
  data lands in the store.
- Reset noRepository / workspacePath in resetDiscoveryState so
  reopening the dialog after a None-mode task doesn't carry the
  flag forward.
- Folder-picker breadcrumb: dropped dead `i > 0 && i > 1` condition
  (always equivalent to `i > 1`); chevrons now render between every
  segment as expected.
- list-dir HTTP handler returns a generic error message instead of
  forwarding raw OS errors that leak host paths.
- Document the dataDir → kandevHome inversion convention next to
  the scratch-path computation so future readers don't mistake the
  filepath.Dir call for a fragile derivation.

* fix: address CodeRabbit review nitpicks

- Trim whitespace on workspace_path before persisting to task metadata
  so a value of "   " doesn't get stored as a non-empty path.
- Extract the workspace_path metadata key into models.MetaKeyWorkspacePath
  alongside the existing MetaKeyAgentProfileID / MetaKeyExecutorProfileID
  so the set/read sites can't drift apart.
- Folder picker: reset cached listing when the popover closes, so
  reopening reloads from `value` instead of showing the last browsed
  directory the user navigated away from without committing.
- Submit payload: send `workspace_path: undefined` instead of an empty
  string when the trimmed value is empty, matching the noRepository=false
  case and keeping the API contract clear.

* fix: scratch path lands in kandev home, not its parent

Claude caught a real bug — `m.dataDir` is set from `cfg.ResolvedHomeDir()`
in cmd/kandev/agents.go, not `ResolvedDataDir()` (the field name is
misleading and the comment was wrong). My previous use of
`filepath.Dir(m.dataDir)` therefore walked one level above the kandev
home, so scratch dirs landed at e.g. `~/tasks/<ws>/<task>` instead of
`~/.kandev/tasks/<ws>/<task>` — and at the project root in dev mode
where `KANDEV_HOME_DIR=<project>/.kandev-dev`, which is what the user
saw on their machine.

Use `m.dataDir` directly (it's already the kandev home), document the
field's misnamed-but-correct semantics, and tighten the test fixtures.

Also:
- folder-picker trigger gets `cursor-pointer` (CodeRabbit nitpick).

* fix: pass workspacePath through handleCreateWithoutAgent too

The "create without agent" path called buildCreateTaskPayload without
the new workspacePath field, so picking a folder + clicking "Create
without starting" silently dropped the path. Now matches the
performCreate (with-agent) path.

* fix: satisfy DialogFormState in new-subtask form stub

PR #852 (merged after this branch was created) introduced
new-subtask-form-state.ts which stubs out a DialogFormState for the
subtask dialog. Adding noRepository / setNoRepository / workspacePath /
setWorkspacePath to the type made that stub incomplete, breaking the
Next.js production build (caught by CI, missed by tsc --noEmit).

Add the four inert fields to the stub, alongside the existing fresh-
branch inert fields, and pull both groups into named constants to
keep the useMemo body under the per-function line cap.

* fix: keep toggle-github-url testid as alias on URL-mode button

The new source-mode segmented control replaced the previous
"or paste a GitHub URL" text link, which had data-testid="toggle-github-url".
The existing e2e suite (create-task-github-url.spec.ts and the new
subtask.spec.ts from #852) still selects the URL toggle by that testid.
Keep "toggle-github-url" as the testid on the URL-mode button so both
suites pass without a sweep across all e2e files. Click semantics are
unchanged — single click enters URL mode.

* test(e2e): update toggle assertion for new source-mode segmented control

create-task-github-url.spec.ts:892 was checking the old text-link
toggle's two-way behavior with text labels ("or paste a GitHub URL" /
"use a workspace repository"). The new segmented control has explicit
mode buttons; assert aria-checked instead of label text. The other 9
clicks on `toggle-github-url` in this file still work via the legacy
testid alias on the URL-mode button.

* refactor: extract useActiveTaskHasRepos to its own hook file

dockview-desktop-layout.tsx grew to 609 lines with the new hook +
the Changes panel guard, over the 600-line cap documented in
CLAUDE.md (lint passed but the convention is real). Move the hook
to hooks/domains/kanban/use-active-task-has-repos.ts. File drops
to 590 lines.

* chore: gitignore .claude/scheduled_tasks.lock runtime file

* fix(web): lock chip-row min-height so modal doesn't shift on mode toggle

The chip row uses flex-wrap; Repo mode (chip + branch + add button +
segmented control) sometimes occupies a different vertical extent than
None mode (single folder picker + segmented control), causing the
create-task modal to jump a few pixels when toggling between sources.
Set min-h-9 so the row always reserves at least one chip-row of
height regardless of the active mode.

* feat: let folder picker browse anywhere, drop allowed-roots gate

The picker is for choosing a starting folder for a repo-less task —
users legitimately want /tmp, /var/log/foo, or any directory they have
read access to. Coupling it to the repo-discover endpoint's
discoveryRoots ($HOME by default) was too tight: paths like /tmp
returned 403 "path is not within allowed roots".

Decouple ListDirectory from discoveryRoots. The repo-discover endpoint
(DiscoverLocalRepositories, ValidateLocalRepositoryPath,
LocalRepositoryStatus, ListBranches) stays anchored to the allowlist
since picking a repo to import is a workspace-scoped operation; the
folder picker trusts the local-process boundary instead.

CodeQL is kept happy by routing the FS operations through os.Root
opened at "/" — the Go stdlib sanitizer for path injection still
recognises it (containment to / is trivially satisfied for every
absolute path on the host).

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-12 01:10:22 +01:00
Carlos Florêncio 472ffb8748 fix(backend): list remote branches for provider-backed workspace repos (#876) 2026-05-11 22:58:54 +01:00
Carlos Florêncio ba24c2963d refactor(logger): close rotating sink on shutdown, add config docs (#877) 2026-05-11 22:55:25 +01:00
José Almeida 0743477d48 feat: persist N-entry FIFO message queue (#864)
* feat(orchestrator): persist N-entry FIFO message queue

Replaces the single-slot, latest-wins, in-memory queue with a SQLite-backed
FIFO queue (default cap 10 per session, configurable via
KANDEV_QUEUE_MAX_PER_SESSION). Drain stays one-per-turn so each entry gets its
own user message and `on_turn_*` workflow cycle. Inter-task `message_task_kandev`
returns a structured `queue_full` error with sender attribution when the
target session is saturated, so the calling LLM can decide whether to retry.

Surfaces a new WS action `message.queue.remove` for single-entry deletion;
`message.queue.cancel` now means clear-all. Edit/remove are keyed on the
entry's UUID, returning `entry_not_found` when atomic-take won the drain race.

* feat(web): N-entry queue state, API, and optimistic hook

Replaces the single-slot QueueStatus with an ordered `entries[]` per session
plus capacity meta (`count`, `max`). API exposes typed `QueueFullError` and
`QueueEntryNotFoundError`; the hook surfaces optimistic edit/remove with
drain-race rollback (refetch on `entry_not_found`).

* feat(web): inline ghost-message queue UI with sender chips

Replaces the floating QueuedMessageIndicator overlay with a stack of ghost
messages rendered above the chat input. Each ghost shows a sender chip
(user / agent) and per-entry edit/remove. A small "n/max queued · clear all"
pill summarizes capacity. Inter-task entries are visually distinct and edit
is gated to the original sender.

* fix(queue): address review — auth guard, TOCTOU, error UX

- Add `messagequeue.QueuedByUser` / `QueuedByAgent` constants and centralize
  the `queue_full` error code so the WS and MCP handlers stay in sync.
- WS `message.queue.{add,append,update}` default empty user_id to QueuedByUser
  so the UpdateContent ownership guard always runs against a non-empty owner;
  inter-task entries (`queued_by="agent"`) can no longer be overwritten by
  user-initiated edits even when `user_id` is omitted from the payload.
- memoryRepository: hold the mutex through the whole append-or-insert path
  (prior split released the lock between tail-check and Insert, allowing two
  same-sender callers to race past coalescing). TransferSession now shifts
  source positions past the destination's max so transferred entries always
  drain after pre-existing destination entries, matching SQLite semantics.
- Frontend: surface edit/clear/remove failures via `sonner` toasts (drain race
  → "message already sent"); exit edit mode on failure instead of leaving the
  textarea silently stuck. Hide the remove button on read-only (agent) ghosts.
- `useQueue.clearAll` no longer derives `max` from pre-clear entry count;
  resets to `metaMax ?? 0` so the next status_changed event lands authoritative
  capacity.
- `senderKindOf` drops the unreachable metadata fallback; route on `queued_by`
  alone since `dispatchTaskMessage` always sets it.
- Test loggers now write to stderr per CLAUDE.md "stderr for logs" guideline.

* test(web): align queue e2e selectors with new ghost-message UI

The ghost-message rewrite renamed button titles ("Cancel queued message"
→ "Remove queued message", "Edit message" → "Edit queued message") and
introduced a `data-testid="queued-ghost-list"` container. The
`message-queue.spec.ts` Playwright tests still pointed at the old titles.

* fix(queue): address second review pass — auth, drain race, tests

- WS handlers (`add` / `append` / `update`) now reject `user_id="agent"`
  outright, closing the gap a hostile WS client could exploit by
  impersonating the inter-task sender identity to overwrite agent
  entries (greptile P1).
- WS `update` / `remove` now require `session_id` so the post-mutation
  `MessageQueueStatusChanged` broadcast can never be silently skipped.
- `sqliteRepository.TakeHead` checks `RowsAffected()` after DELETE;
  when two concurrent take callers race past the busy-timeout the
  loser sees 0 affected rows and now returns `nil, nil` instead of
  the already-drained message (would otherwise dispatch the same
  prompt twice).
- `Service.GetStatus` returns `Entries: []` instead of `nil` on the
  error path so the wire shape stays consistent (frontend already
  guards but strict consumers wouldn't tolerate the type mismatch).
- New tests: `queue-api.test.ts` covers `rethrowQueueError` mappings
  (queue_full / entry_not_found / generic / Error pass-through);
  `session-slice.upsert.test.ts` covers `setQueueEntries`,
  `removeQueueEntry`, and `clearQueueStatus`. Backend gains an
  agent-impersonation handler test.
- `rethrowQueueError` now surfaces `WSError.message` for non-queue
  errors instead of stringifying the whole object.
- Cleanup: replace unused `a, c := ...` discards in service_test
  with `_, _ = ...`.

* fix(queue): scope update/remove by session_id (cross-session deletion guard)

The MCP `queue_full` error payload deliberately discloses sibling-task
entry IDs (so the calling agent's LLM can reason about the wedge state).
Without a session_id filter on `DeleteByID` / `UpdateContent`, a hostile
agent that received that payload could call `wsRemoveEntry` /
`wsUpdateMessage` against another task's entries and delete or rewrite
them — only the per-entry UUID was being checked.

- `Repository.UpdateContent` and `Repository.DeleteByID` now require
  `sessionID` and add `AND session_id = ?` to the SQL guard. The memory
  repo mirrors the scope (lookup keyed on session before the entry scan).
- `Service.UpdateMessage` / `Service.RemoveEntry` take `sessionID`; the
  WS handlers thread `req.SessionID` through (already validated as
  non-empty in the previous fixup).
- TypeScript: `session_id` is now required on `updateQueuedMessage` /
  `removeQueuedEntry` params so callers fail at compile time, matching
  the backend contract.
- Tests cover the cross-session attack: `RemoveEntry`/`UpdateMessage`
  with an attacker-supplied session_id but a real entry id from another
  session must return `ErrEntryNotFound` and leave the victim entry
  untouched. SQLite repo gets parallel coverage.

* fix(queue): propagate transfer error + tighten WSError narrowing

- `Service.TransferSession` now returns an error so callers can fail
  closed instead of silently leaving entries orphaned on the previous
  session. The two existing call sites pick the right policy:
  `switchSessionForStep` (reusing an existing session) propagates the
  error so the workflow switch aborts; the new-session path logs and
  continues, since the new session is already committed and rolling
  back here would leave a half-switched workflow.
- `asWSError` now also narrows on `message` / `details`, so WS payloads
  without a `code` field are still mapped to a meaningful `Error.message`
  via `rethrowQueueError` (instead of "[object Object]"). Native
  `Error` instances explicitly skip the WS-error branch so they pass
  through untouched, preserving the existing pass-through guarantee.
- `queue-api.test.ts`: `expect.assertions(N)` on the catch-only tests
  so a missing throw fails the test instead of silently passing.

* fix(queue): check TransferSession error in service tests

The TransferSession signature change in 83abe6d7 broke errcheck on two
test sites that hadn't been updated. Wrap them in require.NoError.
2026-05-11 22:40:36 +01:00
irium f173e8ad05 feat(logger): add size/age/backup rotation for file output (#874) 2026-05-11 21:52:12 +01:00
Carlos Florêncio 00fed4661e fix(orchestrator): resolve subtask base_branch correctly across repos (#870) 2026-05-11 20:34:04 +01:00
Carlos Florêncio f97ac8bbc1 feat: streaming agent install, PTY login terminal, docker container UX (#869) 2026-05-11 17:52:05 +01:00
José Almeida 14c19ea538 feat(web): add CI hover popover on PR top-bar button (#846)
* feat(github): plumb CI popover fields + branch protection cache

Adds RequiredReviews/UnresolvedReviewThreads/ChecksTotal/ChecksPassing
to TaskPR + PRStatus. Extends the GraphQL PR query with reviewThreads
to populate UnresolvedReviewThreads on every poll. New branch_protection.go
holds an in-memory TTL cache + BranchProtectionFetcher interface;
PATClient/GHClient implement REST/CLI lookups so SyncTaskPR can resolve
required_approving_review_count for the PR's base branch. SyncTaskPR
preserves existing counts when a lightweight (GraphQL) sync returns 0
so the popover doesn't flap during the polling cycle.

* feat(github): mock controller hooks for CI popover e2e

Extends associateTaskPR to accept review/check counts, required_reviews,
and unresolved_review_threads, and switches to ReplaceTaskPR so re-POSTing
the same task drives the popover live-refresh path. Adds POST /pr-feedback
to seed PRFeedback (checks/reviews/comments) keyed by a deterministic
synthetic head_sha, and PUT /auth-health to flip the mock client's
authenticated flag for the "Reconnect GitHub" branch. Mock client gains
ReplaceCheckRuns/ReplaceReviews/ReplaceComments helpers and an
authenticated field that defaults to true.

* feat(web): CI hover popover on PR top-bar button

Hovering the PR top-bar button now reveals a popover with
Passed/In-Progress/Failed counts, a workflow-rollup list under the
non-empty buckets (with "open on GitHub" + "+ ctx" affordances on
failed rows), a review-state row ("Approved 1 / 2 required"), and
unresolved-comments count. Click on the button keeps the existing
"open PR detail panel" behavior so shipped tests stay green.

PRFeedback is lazy-fetched on hover-open via a new SWR-style hook
backed by a 20-entry LRU cache slice; the popover refetches whenever
the underlying TaskPR's updated_at changes (the WS handler already
applies poll deltas). Bucket logic + workflow grouping live in a
small util with vitest coverage. The aggregate-popover variant for
multi-PR tasks ships but the multi-PR trigger preserves its existing
DropdownMenu so per-row click → addPRPanel still works.

* test(e2e): pr-topbar-popover scenarios A–F covering CI popover

11 specs covering: count rows + zero-bucket hiding, workflow rollup
with (N/M passed) badge under Failed, review row + unresolved comments,
hover open + leave close, failed-row open/+ctx affordances, header
external link, "No checks have started" empty state, "Reconnect GitHub"
auth-lost branch, and live count updates on a re-associate.

Adds api-client helpers (mockGitHubSeedPRFeedback, mockGitHubSetAuthHealth,
extended mockGitHubAssociateTaskPR) and SessionPage popover accessors.

* fix(github): address bot review comments on CI popover

- branch_protection.go: evict stale entries on read so the cache map
  cannot grow unbounded over a long-running deployment; rewrite the
  comment to drop the inaccurate singleflight claim (no dedup logic
  exists, concurrent misses race harmlessly to set()).
- gh_client.go: replace fragile strings.Contains "HTTP 404" with an
  isNotFoundErr helper that tolerates the formats current and future
  gh CLI versions use ("HTTP 404", "404 Not Found", "status: 404").
- graphql.go: extend reviewThreads with totalCount and attribute the
  un-fetched tail to the unresolved bucket so PRs with >100 threads
  don't silently undercount in the popover.
- pr-ci-popover.tsx: drop the dead "icon" initializer in PRReviewRow
  (every branch overwrites it). Memoize useAddCheckToContext so
  PRWorkflowRow's reference equality stays stable across renders.
- pr-topbar-button.tsx: collapse the duplicate JSDoc on
  usePopoverInteractions.

* fix(github): address remaining bot review comments + tests

Real correctness fixes:
- Replace the "0/0 means counts omitted" heuristic with an explicit
  PRStatus.ChecksPopulated flag so the rich REST sync can clear stale
  totals when checks are removed from a PR upstream.
- Refresh tp.BaseBranch from status.PR on each SyncTaskPR so a
  retargeted PR's branch-protection lookup keys off the new branch.
  Persist base_branch in UpdateTaskPR.
- Cache 403 from /branches/.../protection (token lacks Administration:
  Read scope) the same way as 404 — otherwise every poller tick burns
  rate-limit quota retrying. Both PATClient and GHClient.
- usePRCIPopover: refetch on every popover-open transition (not just
  on updated_at change), via a wasEnabledRef gate. Restores the
  intended SWR semantics that the lastSyncedRef-only guard suppressed.
- groupChecksByWorkflow: prefer the URL of a job matching the group's
  bucket so clicking a "failed" workflow row navigates to a failing
  job, not an earlier passing one.

Tests:
- branch_protection_test.go: cache hit/miss/TTL/eviction, error
  not cached, no-rule cached, client-without-fetcher path.
- pat_client_test.go: TestPATClient_FetchBranchProtection table-test
  for 200/200-with-null/404/403/500.
- service_test.go: ChecksPopulated preserve-on-zero, accept real
  zero, base-branch retarget, change-publish for the new fields.

* refactor(github): trim associateTaskPR + drop unreachable PRAggregatePopover

- mock_controller.go: extract associateTaskPRRequest struct,
  buildTaskPRFromRequest, and ensureMockPRForRequest helpers so the
  handler is back under the 80-line Go limit.
- pr-ci-popover.tsx: remove unreachable PRAggregatePopover export.
  Multi-PR tasks currently use the existing DropdownMenu trigger
  (no hover popover), so the aggregate component was dead code.

* fix(github): drop unused anchorRef + tests for is{NotFound,Forbidden}Err

- pr-topbar-button.tsx: anchorRef was leftover from the dropped
  click-to-pin onInteractOutside guard. Removed.
- gh_client_test.go: tests for the new isNotFoundErr / isForbiddenErr
  helpers covering each accepted format and a few non-matches so a
  future gh CLI wording change is caught.

* fix(github): UnresolvedReviewThreadsPopulated guard + drop unreachable nil checks

- service.go: mirror the ChecksPopulated/preserve dance for
  UnresolvedReviewThreads. The REST single-PR poller (getPRStatus)
  doesn't fetch review threads, so without this guard every REST
  sync would clobber the GraphQL-populated value to 0 and emit a
  spurious github.task_pr.updated event. The GraphQL path
  (convertBatchedPRResult) sets the new flag.
- branch_protection.go: drop unreachable s == nil / fetcher == nil
  checks. fetchRequiredReviews is only invoked from SyncTaskPR on a
  service constructed via NewService, which always initialises the
  cache. After a successful type assertion, the interface value
  cannot be nil; the !ok branch already covers nil-interface inputs.
- service_test.go: new TestSyncTaskPR_UnresolvedThreads_PreservesOnLightweightSync
  covers the regression. Existing NewFieldsPublishEvent threads-change
  case updated to set UnresolvedReviewThreadsPopulated.

* refactor(web): drop CI popover progress bar — last-updated is enough

The animated progress bar was visual noise; the "updated Ns ago"
timestamp tells the user the same thing without the indicator. Footer
also hides entirely until the first fetch lands so an empty popover
doesn't show a useless "" line.

* fix(web): background-sync PRFeedback so popover is fresh on first hover

The on-hover SWR fetch was the only refresh path, so opening the popover
showed the previously-cached value while a new fetch raced behind it —
visibly stale until the refetch landed. The button icon, by contrast,
updates the moment a github.task_pr.updated WS push lands.

Add a usePRFeedbackBackgroundSync hook mounted at the PRTopbarButton
that subscribes to the same pr.updated_at change signal and refetches
PRFeedback into the cache in the background. The popover hook still
fires an on-open refetch as a safety net for the very first hover
(before any sync). No extra polling — fetches are gated on real
TaskPR changes, matching the icon's cadence.

* feat(web): CI status chip in chat status bar + tweak elapsed label

- New <PRStatusChip> renders a compact colored circle in the chat
  status bar (left of the TodoIndicator, just above the input). Hover
  opens the same PRCIPopover anchored to the chip's top edge so the
  card expands upward — matching the user's "open bottom-up" ask. The
  chip mounts the background-sync hook so the popover is fresh on
  first hover even when the top-bar PR button isn't visible.
- elapsedLabel: "just now" → "updated just now" so the footer reads
  the same regardless of how recently the cache was refreshed.

* fix(web): chip color was missing — use static class map for Tailwind JIT

\`getPRStatusColor().replace("text-", "bg-")\` produced class strings at
runtime, which Tailwind's content scanner doesn't pick up — the chip
rendered with no background. Replace with a static lookup so each
literal `bg-*` class appears in the source and gets compiled into the
bundle.

* fix(web): chip uses currentColor SVG so the dot actually renders

The previous bg-* approach relied on Tailwind compiling the literal
class strings. Even with a static map some palette entries (notably
bg-muted-foreground) didn't surface as a usable utility, so the chip
remained transparent. Apply getPRStatusColor's text-* class to the
button wrapper and fill an SVG circle with currentColor — the text-*
utilities are already compiled (PRTaskIcon uses them) so the dot
is guaranteed to render in the right color.

* fix(web): redesign PR status chip — clipboard icon + status glyph

- Move chip to right of TodoIndicator (was left).
- Render as "[clipboard icon] [status glyph]" instead of a bare circle:
  passed → green CircleCheck, failed → red CircleX, in_progress →
  yellow Loader2 (spinning), merged/neutral → muted dot. Status icons
  reuse already-compiled text-* utilities so colors render reliably.

* fix(github): ReviewCount = approved-reviewer count + ReviewCountsPopulated guard

Two bugs feeding the popover's "Approved (0)" misreport:

- Semantic: REST path set ReviewCount to len(reviews), which double-counts
  authors who left multiple reviews and includes COMMENTED rows. Replace
  with countApprovedReviewers — distinct authors whose latest review
  state is APPROVED. GraphQL converter gets the parallel
  countApprovedReviewerNodes helper and also populates PendingReviewCount
  from ReviewRequests.TotalCount (was unset, so the chip's "(K requested)"
  suffix was always missing).

- Clobber: GraphQL polls used to write ReviewCount/PendingReviewCount=0
  on every cycle because the converter never set them. Add
  ReviewCountsPopulated to PRStatus and gate SyncTaskPR's writes on it
  the same way ChecksPopulated already does, so a partial sync path
  preserves the persisted values instead of resetting them to zero.

* fix(web): popover nitpicks (icon, review row, indent, badge)

- Chip icon: ClipboardCheck → ListCheck.
- PRReviewRow always renders (drop the early-return). Fresh PRs with
  no approvals + no protection rule now show "Awaiting review" instead
  of vanishing.
- Indent workflow rows under the In Progress / Failed group headers
  (pl-5) so the hierarchy reads more clearly.
- In-progress workflow badge: "0/1 ran" → "1 running". The old phrasing
  hid whether the job had even started.

* feat(web): stacked pass-rate progress bar in CI popover

Mirrors TodoIndicator's progress bar at the top of the CI section, but
split into three coloured segments:
  green (passed) | yellow (in_progress) | red (failed)
sized proportionally to each bucket's count. The label reads
"Pass rate · K/N (P%)" so the user gets the same at-a-glance summary
TODOs shows. Skipped/cancelled-but-not-failed jobs aren't counted —
the bar always reflects the actionable picture.

* fix(web): right-align review counts + drop chip hover bg

- Move approval/required counts to the right edge of the review row
  ("Approved" on the left, "1 / 2" on the right), mirroring the
  bucket-row layout. The "(K requested)" suffix becomes a "· K requested"
  trailing fragment on the right side.
- PRStatusChip: drop hover:bg-accent/50 so the trigger doesn't grow a
  background plate when the popover is open.

* fix(web): switch CI chip icon to IconChecklist

* fix(github): clear bot review-comment backlog

Real correctness fixes:
- pr-status-chip: pending checks / pending review now beat
  checks_state === "success" in chipStatus precedence — a PR with
  CI green but reviewers outstanding is no longer rendered as
  "passed".
- branch_protection: drop the unreachable Reset() method.
- pr-topbar-button: stop double-mounting usePRFeedbackBackgroundSync;
  the chip already covers it.
- use-pr-ci-popover: dedupe key combines prFeedbackKey + updated_at
  so switching tasks with same-timestamp PRs still refetches.
- use-pr-ci-popover: drop stale comment about the removed progress bar.

Refactor:
- client_helpers + graphql: extract reviewSample +
  latestReviewStateByAuthor helper so the three near-identical
  per-reviewer dedup loops share one implementation.

Tests:
- countApprovedReviewers + reduceReviewSummary unit tests.
- TestSyncTaskPR_ReviewCounts_PreservesOnLightweightSync +
  AcceptsRealZero mirror the existing ChecksPopulated guards.
- groupChecksByWorkflow distinct-URL test verifies the failed-job
  URL-selection fix.
- branch_protection TTL tests now use testing/synctest — instant
  virtual time, no real-sleep flake on CI.

* fix(web): clicking the CI chip no longer closes the popover

HoverCard treats the trigger button as outside the content's bounding
box, so a click on the chip fired Radix's onPointerDownOutside and
auto-closed the popover. Add a triggerRef + onPointerDownOutside guard
on HoverCardContent that filters out clicks landing on the trigger —
clicking the chip is now a no-op while the popover stays open via hover.
2026-05-11 16:55:58 +01:00
João Salavisa 8ce989507a fix(orchestrator): keep task and session state in sync on tool-event wake (#865)
* fix(orchestrator): keep task and session state in sync on tool-event wake

handleToolCallEvent and handleToolUpdateEvent woke the session from
WAITING_FOR_INPUT to RUNNING via updateTaskSessionState, but never
touched task.state. An out-of-turn tool event (e.g. a Monitor watcher
firing after on_turn_complete moved the task to REVIEW) left the task
parked in REVIEW while the session showed RUNNING — surfacing as a task
that "always works, never leaves state". Use setSessionRunning so
task.state flips to IN_PROGRESS in lockstep.

* fix(orchestrator): guard setSessionRunning against terminal sessions

Greptile flagged that setSessionRunning unconditionally wrote
tasks.state=IN_PROGRESS even when updateTaskSessionState silently
no-op'd because the session was already terminal. With the new
streaming-handler call sites, a buffered tool event arriving after
a CANCELLED/FAILED/COMPLETED session would leave the session
correctly terminal but flip the task back to IN_PROGRESS.

Resolve the session up front and short-circuit when terminal so
neither write fires. Add paired test sub-cases that pre-seed the
session as CANCELLED and assert the task state is not written.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-11 13:39:39 +01:00