Skip to content

Feat/captain subagent history#13

Merged
moshloop merged 22 commits into
mainfrom
feat/captain-subagent-history
Jun 30, 2026
Merged

Feat/captain subagent history#13
moshloop merged 22 commits into
mainfrom
feat/captain-subagent-history

Conversation

@moshloop

@moshloop moshloop commented Jun 30, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features
    • Added session browsing and session transcript viewing in both CLI and web UI.
    • Introduced a new serve command to run the HTTP API and embedded web app.
    • Added whoami to inspect backend availability, authentication status, and (optionally) model access.
    • Expanded AI workflow support with agent runs and more configurable prompts.
  • Bug Fixes
    • Improved session-id filtering across history and cost reporting.
    • Enhanced working-directory handling and backend/model selection.
    • Improved AI cost and logging breakdowns (including richer schema/source visibility).

moshloop added 10 commits June 29, 2026 21:30
Promote google/uuid to a direct dependency to support the new spec frontmatter parsing feature.

Add decodeSpecFrontmatter function that re-encodes the raw dotprompt frontmatter map as YAML and unmarshals it into ai.Request, allowing spec-native keys (permissions, memory, budget, context, maxTurns) to be declared directly in .prompt files. The dotprompt config block remains canonical for the three knobs it owns (maxOutputTokens, temperature, reasoning). Extend test coverage with new fixtures for all supported backends (Anthropic, ClaudeAgent, ClaudeCLI, CodexCLI, Gemini, GeminiCLI, OpenAI) and a dedicated spec frontmatter test.
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@moshloop, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9a04c45b-0085-4777-9b09-bb1149a7ca44

📥 Commits

Reviewing files that changed from the base of the PR and between 526166f and 764a29c.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (36)
  • cmd/captain/main.go
  • go.mod
  • pkg/ai/catalog.go
  • pkg/ai/catalog_info.go
  • pkg/ai/catalog_info_test.go
  • pkg/ai/catalog_resolve.go
  • pkg/ai/catalog_resolve_test.go
  • pkg/ai/catalog_test.go
  • pkg/ai/client.go
  • pkg/ai/effort.go
  • pkg/ai/model_cache.go
  • pkg/ai/models_remote_test.go
  • pkg/ai/pricing/openrouter.go
  • pkg/ai/pricing/registry.go
  • pkg/ai/pricing/static.go
  • pkg/ai/pricing/static_test.go
  • pkg/ai/provider.go
  • pkg/ai/provider/cli.go
  • pkg/ai/provider/cmux/executor.go
  • pkg/ai/provider/cmux/executor_test.go
  • pkg/ai/provider/cmux/sessionstats.go
  • pkg/ai/types.go
  • pkg/api/runtime_config.go
  • pkg/api/runtime_event.go
  • pkg/api/runtime_provider.go
  • pkg/api/runtime_registry.go
  • pkg/claude/history.go
  • pkg/claude/plan.go
  • pkg/claude/plan_test.go
  • pkg/claude/reader.go
  • pkg/claude/reader_test.go
  • pkg/claude/tools/plan.go
  • pkg/claude/tools/plan_test.go
  • pkg/cli/ai_filters_test.go
  • pkg/cli/plan.go
  • pkg/cli/plan_test.go

Walkthrough

This PR adds canonical pkg/api models, refactors pkg/ai and providers to use typed request/config fields, introduces a cmux-backed provider, expands CLI commands and session filtering, and adds an embedded React web UI.

Changes

Captain: api types, cmux provider, CLI commands, and web UI

Layer / File(s) Summary
API models and ai aliases
pkg/api/*, pkg/ai/types.go, pkg/ai/provider.go, pkg/ai/client.go, pkg/ai/pricing/registry.go, pkg/ai/typed.go, pkg/ai/*_test.go
Canonical backend/model/prompt/spec/budget/context/memory/permissions/cost/schema/pretty types are added, and ai.Request/Usage/Cost/Costs/Config are updated to use them. Backend lookup and pricing now flow through the new typed API.
AI request plumbing and middleware
pkg/ai/middleware/*, pkg/ai/agent.go, pkg/ai/agent/runner.go, pkg/ai/agent_test.go, pkg/ai/agent/runner_test.go, pkg/ai/history/codex_parser.go, pkg/ai/history/codex_parser_test.go
Request construction, prompt source tracking, typed prompt inputs, cost accumulation, and logging now use api.Prompt, api.Model, api.Budget, and req.Prompt.Schema; runner and Codex parsing also move to the new context/session fields.
Prompt rendering and fixtures
pkg/ai/prompt/*, pkg/ai/prompt/testdata/*
Dotprompt rendering now decodes full frontmatter into typed request/config data, maps role messages into nested prompt fields, and updates prompt fixtures/tests for the new model/budget/permissions shape.
cmux provider and session tracking
pkg/ai/provider/cmux/*, pkg/ai/provider/init.go, pkg/ai/provider/claudeagent/*, pkg/ai/provider/codex_appserver*.go, pkg/ai/provider/gemini_cli.go, pkg/ai/provider/genkit/*, pkg/ai/provider/cli.go
Adds the cmux client/executor/provider, session log tailing and stats, stall watchdog, submission helpers, and updates Claude/Codex/Gemini/Genkit providers to the typed request shape and new config fields.
CLI commands and session/cost filtering
cmd/captain/main.go, pkg/cli/ai.go, pkg/cli/ai_agent.go, pkg/cli/serve.go, pkg/cli/whoami.go, pkg/cli/sessions.go, pkg/cli/chat_thread_store.go, pkg/cli/history.go, pkg/cli/cost.go, pkg/cli/stdin.go, pkg/cli/session_filter.go, pkg/cli/configure.go, pkg/cli/ai_models.go, pkg/cli/ai_catalog.go, pkg/cli/ai_filters.go, pkg/cli/*_test.go
Adds ai agent, serve, whoami, and sessions commands, a JSON-backed chat thread store, session-ID filtering through history/cost paths, updated configure/model/catalog/filter helpers, and typed CLI request/config builders with validation.
Embedded web UI
pkg/cli/webapp/*, .gitignore, README.md, go.mod
Adds the Vite/React/Tailwind web app, route/session helpers, launcher/browser views, embedded static serving, and docs/dependency/ignore updates.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and relevant to the PR’s main theme of captain session/history work, including subagent and session browsing changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/captain-subagent-history
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/captain-subagent-history

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@socket-security

socket-security Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedgolang/​github.com/​flanksource/​commons@​v1.51.3 ⏵ v1.53.171 +1100100100100
Addedgolang/​github.com/​flanksource/​clicky/​aichat@​v1.21.34-0.20260630075958-cd55f2f4fce399100100100100

View full report


function git(args: string, fallback: string) {
try {
return execSync(`git -C ${JSON.stringify(clickyPackageRoot)} ${args}`, {

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
pkg/ai/agent.go (1)

89-97: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the same model fallback in the success response.

accrue() now falls back to a.cfg.Model.Name when providers leave resp.Model empty, but ExecutePrompt() still returns PromptResponse.Model = resp.Model on success. That leaves successful responses with a blank model while the associated cost record uses the configured one.

Proposed fix
 	cost := a.accrue(resp)
+	model := resp.Model
+	if model == "" {
+		model = a.cfg.Model.Name
+	}
 	return &PromptResponse{
 		Request:        req,
 		Result:         resp.Text,
 		StructuredData: resp.StructuredData,
 		Costs:          Costs{cost},
-		Model:          resp.Model,
+		Model:          model,
 		Duration:       time.Since(start),
 		CacheHit:       resp.CacheHit,
 	}, nil
 }

Also applies to: 160-164

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/agent.go` around lines 89 - 97, ExecutePrompt() is returning a blank
PromptResponse.Model when providers omit resp.Model, while accrue() already
applies the configured fallback. Update the success response construction in
ExecutePrompt() to use the same fallback logic as accrue(), so
PromptResponse.Model is set to resp.Model when present or a.cfg.Model.Name
otherwise. Make the same change in the other return path referenced by the
review so both success responses stay consistent with the cost record.
pkg/ai/models_remote_test.go (1)

138-140: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Clear GOOGLE_API_KEY in the missing-key test.

GetAPIKeyFromEnv(BackendGemini) falls back to GOOGLE_API_KEY, so this test can become host-environment dependent unless that variable is cleared too.

Suggested fix
 	t.Setenv("OPENAI_API_KEY", "")
 	t.Setenv("ANTHROPIC_API_KEY", "")
 	t.Setenv("GEMINI_API_KEY", "")
+	t.Setenv("GOOGLE_API_KEY", "")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/models_remote_test.go` around lines 138 - 140, The missing-key test is
still dependent on host environment because GetAPIKeyFromEnv(BackendGemini) can
read GOOGLE_API_KEY as a fallback even when GEMINI_API_KEY is empty. Update the
test setup in the missing-key case to clear GOOGLE_API_KEY alongside
OPENAI_API_KEY, ANTHROPIC_API_KEY, and GEMINI_API_KEY so the behavior is fully
isolated and deterministic.
pkg/cli/ai.go (1)

53-80: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

--budget 0 cannot disable a saved budget cap.

parseFloatFlag returns 0 for both “flag omitted” and --budget 0, and Line 71 then always falls back to saved.BudgetUSD. After a user saves a budget in captain configure, commands that go through ToConfig() can no longer request the documented “0 = unlimited” behavior from the CLI.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cli/ai.go` around lines 53 - 80, The AIProviderOptions.ToConfig flow is
treating an explicit --budget 0 the same as an omitted flag, so it incorrectly
falls back to saved.BudgetUSD. Update ToConfig to distinguish “flag not
provided” from an explicit zero value when using parseFloatFlag, and only
inherit the saved budget when the budget flag was actually unset. Keep the
existing validation and config assembly in AIProviderOptions.ToConfig, but
ensure api.Budget{Cost: budget} can remain 0 to mean unlimited.
🟠 Major comments (23)
pkg/ai/provider/genkit/options.go-125-128 (1)

125-128: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve Prompt.AppendSystem in Genkit requests.

AppendSystem is now ignored for Genkit providers, so callers using appended system instructions lose them.

Proposed fix
-	if req.Prompt.System != "" {
-		opts = append(opts, gkai.WithSystem(req.Prompt.System))
+	system := req.Prompt.System
+	if req.Prompt.AppendSystem != "" {
+		if system != "" {
+			system += "\n\n"
+		}
+		system += req.Prompt.AppendSystem
+	}
+	if system != "" {
+		opts = append(opts, gkai.WithSystem(system))
 	}
 	opts = append(opts, gkai.WithPrompt(req.Prompt.User))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/provider/genkit/options.go` around lines 125 - 128,
`genkit/options.go` is dropping appended system instructions because
`req.Prompt.System` is the only system text passed into `gkai.WithSystem`.
Update the request-building logic in `options.go` to preserve
`Prompt.AppendSystem` for Genkit providers by combining it with the existing
system prompt before calling `gkai.WithSystem`, while still passing
`req.Prompt.User` through `gkai.WithPrompt`.
pkg/ai/provider/genkit/genkit.go-65-65 (1)

65-65: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Honor cfg.Model.ID when constructing the Genkit model ref.

pkg/api/model.go defines ID as the provider-specific override for cases where the backend id differs from the catalog name. Building ref from cfg.Model.Name alone throws that away, so Genkit-specific ids can resolve the wrong model or fail outright.

Proposed fix
-	ref, err := modelRef(backend, cfg.Model.Name)
+	modelID := cfg.Model.ID
+	if modelID == "" {
+		modelID = cfg.Model.Name
+	}
+	ref, err := modelRef(backend, modelID)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/provider/genkit/genkit.go` at line 65, The Genkit model reference is
being built from cfg.Model.Name only, which ignores the provider-specific
override in cfg.Model.ID. Update genkit.go so the ref construction in the code
path using modelRef and cfg.Model includes cfg.Model.ID when present, falling
back to cfg.Model.Name only when ID is empty. Make the change in the logic
around modelRef(backend, ...) so Genkit resolves the intended backend model
identifier.
pkg/ai/provider/gemini_cli.go-48-50 (1)

48-50: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve the full prompt when calling Gemini CLI.

This now drops Prompt.System and Prompt.AppendSystem entirely. Any prompt template that relies on system instructions will behave differently on Gemini CLI than on the other provider paths.

Proposed fix
 	cliReq := map[string]string{
-		"prompt": req.Prompt.User,
+		"prompt": composePrompt(req),
 		"model":  g.model,
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/provider/gemini_cli.go` around lines 48 - 50, The Gemini CLI request
building in GeminiCli should preserve the complete prompt instead of using only
req.Prompt.User. Update the prompt assembly logic in the GeminiCLI provider so
Prompt.System and Prompt.AppendSystem are included alongside the user content
before populating the cliReq payload, matching the behavior of the other
provider paths and keeping system instructions intact.
pkg/ai/history/codex_parser.go-79-80 (1)

79-80: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not stop at thread.started before reading later session metadata.

ReadCodexSessionInfo now explicitly handles thread.started as something that can precede richer metadata. Returning here means ReadCodexSessionMeta never sees a following session_meta, so cheap history filtering loses CWD/git/provider fields and can exclude valid sessions.

Proposed fix
 func ReadCodexSessionMeta(sessionFile string) (*CodexSessionInfo, error) {
 	file, err := os.Open(sessionFile)
 	if err != nil {
 		return nil, err
 	}
 	defer func() { _ = file.Close() }()
 
 	scanner := bufio.NewScanner(file)
 	scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
+	var info *CodexSessionInfo
 	for scanner.Scan() {
 		line := strings.TrimSpace(scanner.Text())
 		if line == "" {
 			continue
 		}
@@
 		switch event.Type {
 		case "session_meta":
-			info := &CodexSessionInfo{
-				ID:            event.Payload.ID,
-				CWD:           event.Payload.CWD,
-				CLIVersion:    event.Payload.CLIVersion,
-				ModelProvider: event.Payload.ModelProvider,
-				Originator:    event.Payload.Originator,
-				StartedAt:     event.Time(),
-			}
+			if info == nil {
+				info = &CodexSessionInfo{}
+			}
+			if info.ID == "" {
+				info.ID = event.Payload.ID
+			}
+			info.CWD = event.Payload.CWD
+			info.CLIVersion = event.Payload.CLIVersion
+			info.ModelProvider = event.Payload.ModelProvider
+			info.Originator = event.Payload.Originator
+			if info.StartedAt == nil {
+				info.StartedAt = event.Time()
+			}
 			if event.Payload.Git != nil {
 				info.GitBranch = event.Payload.Git.Branch
 				info.GitCommit = event.Payload.Git.CommitHash
 			}
-			return info, nil
 		case "thread.started":
-			return &CodexSessionInfo{ID: event.ThreadID, StartedAt: event.Time()}, nil
+			if info == nil {
+				info = &CodexSessionInfo{StartedAt: event.Time()}
+			}
+			if info.ID == "" {
+				info.ID = event.ThreadID
+			}
 		case "turn_context", "response_item", "event_msg", "item.completed", "turn.failed", "error":
-			return nil, nil
+			return info, nil
 		}
 	}
 	if err := scanner.Err(); err != nil {
 		return nil, err
 	}
-	return nil, nil
+	return info, nil
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/history/codex_parser.go` around lines 79 - 80, The
`ReadCodexSessionInfo` handling for `thread.started` returns too early and
prevents later `session_meta` data from being read. Update the parser flow in
`codex_parser.go` so `thread.started` only initializes `CodexSessionInfo` and
then continues scanning for richer metadata, allowing `ReadCodexSessionMeta` to
pick up CWD/git/provider fields. Use the `ReadCodexSessionInfo` and
`ReadCodexSessionMeta` paths as the key places to adjust control flow without
breaking existing event parsing.
pkg/ai/provider/cmux/stall.go-33-36 (1)

33-36: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Tighten approval-dialog detection before holding the stall clock.

The current regex matches any visible “do you want to” text, including normal prompts or model output. Since awaitingHuman then skips stall checks, a non-dialog phrase can prevent stalled runs from ever failing. Require Claude approval-dialog structure/choices before treating the screen as awaiting approval.

Also applies to: 185-187, 209-219

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/provider/cmux/stall.go` around lines 33 - 36, The approval-prompt
detection in approvalPromptRe is too broad and can misclassify ordinary text as
a human approval dialog, which causes awaitingHuman to bypass stall checks
incorrectly. Tighten the matching in stall.go so it only returns true for
Claude-style approval dialogs with the expected structure and explicit
choices/buttons, and update the awaitingHuman path to rely on that stricter
signal before suspending stall detection.
pkg/ai/provider/cmux/provider.go-118-139 (1)

118-139: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not return success when the stream closes without EventResult.

On cancellation, drive can skip emitting EventError/EventResult because emit honors the canceled context. Execute then falls through and returns a successful empty response. Treat missing EventResult as cancellation or execution failure.

🐛 Proposed fix
-	if !sawResult && lastErr != "" {
-		return nil, fmt.Errorf("%w: %s", ai.ErrCLIExecutionFailed, lastErr)
+	if !sawResult {
+		if lastErr != "" {
+			return nil, fmt.Errorf("%w: %s", ai.ErrCLIExecutionFailed, lastErr)
+		}
+		if err := ctx.Err(); err != nil {
+			return nil, err
+		}
+		return nil, fmt.Errorf("%w: cmux stream closed without a result", ai.ErrCLIExecutionFailed)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/provider/cmux/provider.go` around lines 118 - 139, The success path in
`cmux/provider.go`’s `Execute` currently returns an empty `ai.Response` even
when `drive` finishes without ever producing `EventResult`; update the
post-stream handling to detect that case and return `ai.ErrCLIExecutionFailed`
instead of success. Use the existing `sawResult`, `success`, and `lastErr`
checks near the end of `Execute` to treat a missing result as cancellation or
execution failure, and keep the existing failure-return behavior consistent with
the `EventError`/`EventResult` flow.
pkg/ai/provider/cmux/sessionlog.go-30-42 (1)

30-42: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate sessionID before joining it into a filesystem path.

filepath.Join(..., sessionID+".jsonl") allows ../ or path separators from resume/configured session IDs to escape the expected Claude project directory. Reject non-basename session IDs before constructing the path.

🛡️ Proposed fix
 import (
 	"bytes"
 	"context"
 	"errors"
 	"fmt"
 	"io"
 	"os"
 	"path/filepath"
+	"strings"
 	"time"
@@
 func SessionLogPath(workDir, sessionID string) (string, error) {
+	sessionID = strings.TrimSpace(sessionID)
 	if sessionID == "" {
 		return "", fmt.Errorf("session id is required")
 	}
+	if strings.ContainsAny(sessionID, `/\`) || sessionID == "." || sessionID == ".." {
+		return "", fmt.Errorf("invalid session id %q", sessionID)
+	}
 	abs, err := filepath.Abs(workDir)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/provider/cmux/sessionlog.go` around lines 30 - 42, `SessionLogPath`
currently joins `sessionID` directly into the session log path, which allows
path traversal via separators or `..` values. Add a validation step in
`SessionLogPath` to reject any `sessionID` that is not a plain basename before
calling `filepath.Join`, and return an error for invalid IDs so only safe
session log filenames are constructed.
pkg/ai/provider/cmux/submit.go-121-125 (1)

121-125: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Don’t log the full prompt payload.

At Line 124, text is dumped verbatim into debug logs. That payload can include source code, credentials, and user input, so this turns routine troubleshooting into a log exfiltration path.

Safer logging
 		log.Infof("cmux: sending %s to workspace %s surface %s (attempt %d/%d)", label, workspaceRef, surfaceRef, attempt, attempts)
 		log.Debugf("cmux command: cmux send --workspace %q --surface %q -- <%s>", workspaceRef, surfaceRef, label)
 		log.Debugf("cmux command: cmux send-key --workspace %q --surface %q Enter", workspaceRef, surfaceRef)
-		log.Debugf("cmux send payload:\n%s", text)
+		log.Debugf("cmux: %s payload bytes=%d", label, len(text))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/provider/cmux/submit.go` around lines 121 - 125, The debug logging in
SendSurface is leaking the full prompt payload by printing text verbatim, which
can expose sensitive content. Update the cmux submit flow in submit.go so the
log statement around the SendSurface call no longer includes raw text; keep only
metadata like workspaceRef, surfaceRef, label, and attempt information, or
replace the payload with a redacted/length-only summary. Use the existing
SendSurface and log.Debugf call sites to locate the change.
pkg/ai/provider/cmux/sessionstats.go-500-507 (1)

500-507: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Stop serving finished sessions from the live map.

Finish() only clears InProgress, but Get() still prefers the accumulator as long as it remains in c.live. That means any lines flushed after the tailer stops — or a resumed session writing to the same log — stay invisible until this entry happens to be evicted, so callers can see permanently stale stats.

Proposed fix
 func (c *SessionStatsCache) Get(sessionID, path string) (SessionStats, error) {
 	c.mu.Lock()
 	acc := c.live[sessionID]
 	c.mu.Unlock()
 	if acc != nil {
-		return acc.snapshot(), nil
+		s := acc.snapshot()
+		if s.InProgress {
+			return s, nil
+		}
+		c.mu.Lock()
+		delete(c.live, sessionID)
+		c.mu.Unlock()
 	}
 	return c.coldStats(sessionID, path)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/provider/cmux/sessionstats.go` around lines 500 - 507, The
SessionStatsCache.Get path is still returning a live accumulator for finished
sessions, because it prefers c.live entries even after Finish() has cleared
InProgress. Update Get() (and/or the session lifecycle around Finish() and
c.live) so it only serves live stats while a session is actually in progress;
once finished, fall back to coldStats() or remove the accumulator from c.live so
session snapshots stay current for SessionStatsCache.Get and
SessionStatsCache.Finish.
pkg/ai/middleware/caching.go-25-25 (1)

25-25: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Cache key now ignores the non-user prompt state.

After the api.Prompt refactor, response-shaping fields live outside User (System, AppendSystem, and Schema). Keying and storing the cache entry with only req.Prompt.User means two requests with identical user text but different instructions/output contracts can now collide and return the wrong cached response. Please derive the cache key from the full response-shaping prompt state instead of only the user text.

Also applies to: 53-53

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/middleware/caching.go` at line 25, The cache key in the caching
middleware only uses req.Prompt.User, which can cause collisions when System,
AppendSystem, or Schema differ. Update the cache lookup and storage in the
caching middleware (the Get/Set path around the cache entry handling) to derive
the key from the full response-shaping prompt state, including the non-user
fields from api.Prompt, while keeping the model-specific part via
c.provider.GetModel().
pkg/api/model.go-29-35 (1)

29-35: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Explicit cmux models are rejected by the canonical Model helpers.

BackendClaudeCmux and BackendCodexCmux are declared in pkg/api/enums.go, but Backend.Valid() excludes them because AllBackends() only lists user-facing backends. That means Model{Backend: BackendClaudeCmux} fails both ResolveBackend() and Validate(), which blocks the new cmux provider path from using the canonical api.Model. Please split user-facing backend validation from internal backend support, or allow the internal cmux backends through this path.

Also applies to: 53-55

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/api/model.go` around lines 29 - 35, The canonical Model helpers are
rejecting internal cmux backends because ResolveBackend() and Validate() rely on
Backend.Valid()/AllBackends(), which only cover user-facing values. Update the
backend validation path in Model so BackendClaudeCmux and BackendCodexCmux are
accepted by canonical api.Model while keeping any user-facing list separate; use
the existing ResolveBackend and Validate methods (and any backend list helpers
they call) to split internal backend support from public validation.
pkg/ai/prompt/prompt.go-164-165 (1)

164-165: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep cfg.Model.Effort aligned with the config.reasoning override.

Line 164 only updates req.Effort. If a prompt mixes spec-native effort with config.reasoning, Render returns a request and config that disagree, even though RunAIPrompt passes cfg into ai.NewProvider. That drops the documented “config wins” behavior for any provider that reads cfg.Model.Effort.

Proposed fix
 	if s, ok := c["reasoning"].(string); ok {
-		req.Effort = api.Effort(s)
+		effort := api.Effort(s)
+		req.Effort = effort
+		cfg.Model.Effort = effort
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/prompt/prompt.go` around lines 164 - 165, The `config.reasoning`
override in `Render` currently updates only `req.Effort`, leaving
`cfg.Model.Effort` out of sync and causing `RunAIPrompt`/`ai.NewProvider` to see
conflicting values. Update the `Render` path in `prompt.go` so the same
`reasoning` branch also applies to `cfg.Model.Effort`, keeping the returned
request and config aligned; use the existing `req.Effort`, `cfg.Model.Effort`,
and `config.reasoning` handling in `Render` to make the override consistent.
pkg/cli/sessions.go-150-164 (1)

150-164: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don’t return the first prefix match.

This loop treats key matches, exact session-id matches, and prefix matches as the same first-hit condition. If two sessions share a prefix—or an exact match appears later in the slice—sessions get can open the wrong transcript based purely on discovery order.

Suggested fix
 func RunSessionGet(opts SessionGetOptions) (SessionRecord, error) {
@@
-	for _, candidate := range candidates {
-		if candidate.record.Key != id && candidate.record.ID != id && !strings.HasPrefix(candidate.record.ID, id) {
-			continue
-		}
-		record, err := loadSessionDetail(candidate)
-		if err != nil {
-			return SessionRecord{}, err
-		}
-		return record, nil
-	}
-	return SessionRecord{}, fmt.Errorf("session %q not found", id)
+	var exact []sessionCandidate
+	var prefix []sessionCandidate
+	for _, candidate := range candidates {
+		switch {
+		case candidate.record.Key == id || candidate.record.ID == id:
+			exact = append(exact, candidate)
+		case strings.HasPrefix(candidate.record.ID, id):
+			prefix = append(prefix, candidate)
+		}
+	}
+	matches := exact
+	if len(matches) == 0 {
+		matches = prefix
+	}
+	switch len(matches) {
+	case 0:
+		return SessionRecord{}, fmt.Errorf("session %q not found", id)
+	case 1:
+		record, err := loadSessionDetail(matches[0])
+		if err != nil {
+			return SessionRecord{}, err
+		}
+		return record, nil
+	default:
+		return SessionRecord{}, fmt.Errorf("session %q is ambiguous; use the full key or --source", id)
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cli/sessions.go` around lines 150 - 164, The session lookup in
discover/load flow is returning the first prefix match too early, which can
select the wrong transcript when multiple candidates share an ID prefix. Update
the matching logic in the session retrieval loop in sessions.go so exact matches
on candidate.record.Key or candidate.record.ID are preferred and returned
immediately, while prefix matches are only accepted if no exact match exists;
use discoverSessionCandidates and loadSessionDetail as the key symbols to locate
the flow.
pkg/cli/whoami.go-213-235 (1)

213-235: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the live model probes with a timeout.

Each goroutine calls ai.ListModels(context.Background(), backend) without cancellation, so one slow provider can hang captain whoami --models indefinitely. Please use a per-backend timeout or plumb the command context through this helper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cli/whoami.go` around lines 213 - 235, The live model probe in
fetchAPIModels currently calls ai.ListModels with context.Background(), so a
slow backend can block captain whoami --models forever. Update fetchAPIModels to
accept and use the command context, or create a per-backend timeout context
inside the goroutine before calling ai.ListModels. Make sure the timeout is
applied for each backend probe and that the context is canceled after the call.
pkg/cli/serve.go-153-170 (1)

153-170: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Build listener and browser URLs with proper host/port helpers.

These paths all concatenate host:port manually. That produces invalid addresses for IPv6 hosts (for example ::1:8080) and --open will try to launch unroutable URLs like http://0.0.0.0:8080/. Use net.JoinHostPort for bind addresses and construct the displayed/opened URLs from a url.URL.

Also applies to: 191-195, 315-320

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cli/serve.go` around lines 153 - 170, The server address and
displayed/opened URLs are still being built by concatenating host and port,
which breaks IPv6 and produces bad browser links for non-routable bind hosts.
Update the address construction around the http.Server setup in serve.go to use
net.JoinHostPort for the bind addr, and build the printed/opened endpoints from
a url.URL derived from the actual listening host rather than the raw opts.Host.
Apply the same fix in the later URL-printing/open logic at the other listed
spots so all references use the same helper-based host/port handling.
pkg/cli/serve.go-239-248 (1)

239-248: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Avoid persisting orphan threads on partial failure.

pkg/cli/chat_thread_store.go:32-55 persists Create() immediately, and pkg/cli/chat_thread_store.go:124-139 persists SetProviderSession() in a second write. If the second write fails, this handler returns 500 after already creating a thread with no providerSessionId, so client retries can accumulate duplicates. Roll the create back on error, or persist the session ID in the initial write.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cli/serve.go` around lines 239 - 248, The thread creation flow in the
serve handler leaves an orphan record when `store.SetProviderSession` fails
after `store.Create` has already persisted the thread. Update the `serve.go`
handler to either include `ProviderSessionID` in the initial `store.Create`
write, or explicitly roll back/delete the created thread if the second write
fails, using the `store.Create`, `store.SetProviderSession`, and `store.Get`
path to keep the operation atomic from the client’s perspective.
pkg/cli/webapp/src/session.ts-43-57 (1)

43-57: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't silently remap non-Claude agents to Claude.

For anything outside the codex/claude special cases, Line 57 falls back to claude-agent-sonnet. pkg/cli/webapp/src/AgentLauncher.tsx:30-73 uses that value when it creates the follow-up thread, so gemini/openai launches end up resuming the returned provider session under the wrong chat model. That will misroute follow-up turns or fail thread resume. Please derive the thread model from every supported backend, or surface an explicit “follow-up chat unsupported” error instead of defaulting here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cli/webapp/src/session.ts` around lines 43 - 57, The fallback in
agentModelFor is incorrectly remapping non-Claude backends to the Claude
default, which then causes AgentLauncher to resume follow-up threads under the
wrong model. Update agentModelFor so it returns a provider-specific thread model
for every supported backend or explicitly rejects unsupported follow-up chat in
AgentLauncher, rather than falling through to DEFAULT_AGENT_MODEL; keep the
codex and Claude special cases intact while handling gemini/openai separately.
pkg/cli/webapp/vite.config.ts-13-15 (1)

13-15: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't default vite serve to a sibling clicky-ui checkout.

Line 14 enables source aliases for every dev-server run, but Lines 45-90 then redirect imports into ../../../../clicky-ui/packages/ui/src. On a normal clone without that sibling repo, captain serve --dev fails before the UI starts. Please gate source mode behind an existence check, or make it opt-in instead of the default.

Suggested fix
+import { existsSync, readFileSync } from "node:fs";
-import { readFileSync } from "node:fs";
@@
 export default defineConfig(({ command }) => {
   const useClickySource =
-    command === "serve" && process.env.CAPTAIN_UI_CLICKY_SOURCE !== "0";
+    command === "serve" &&
+    process.env.CAPTAIN_UI_CLICKY_SOURCE !== "0" &&
+    existsSync(clickyPackageRoot);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cli/webapp/vite.config.ts` around lines 13 - 15, The dev-server source
aliasing in vite.config.ts is defaulting to a sibling clicky-ui checkout, which
breaks normal captain serve --dev runs when that repo is absent. Update the
useClickySource logic so source mode is only enabled when the target checkout
actually exists, or make it explicitly opt-in via CAPTAIN_UI_CLICKY_SOURCE; keep
the aliasing block that points to ../../../../clicky-ui/packages/ui/src gated
behind that check.
pkg/cli/webapp/vite.config.ts-115-120 (1)

115-120: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Avoid shell interpolation when running git. execSync() still invokes a shell here, and JSON.stringify(clickyPackageRoot) does not neutralize $()/backticks inside the path. Use execFileSync()/spawnSync() with an argument array instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cli/webapp/vite.config.ts` around lines 115 - 120, The git helper in
vite.config.ts is still using a shell via execSync, and
JSON.stringify(clickyPackageRoot) does not prevent shell injection from the
path. Update the git(args, fallback) helper to run git with execFileSync() or
spawnSync() using an argument array instead of string interpolation, while
keeping the same fallback behavior and preserving the existing git helper call
sites.

Source: Linters/SAST tools

pkg/cli/ai_prompt_file.go-54-78 (1)

54-78: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Explicit zero CLI overrides are lost when a prompt file already sets temperature or budget.

overlayCLI treats 0 as “unset” for both fields: Line 69 skips --temperature 0, and Line 77 ignores --budget 0. That means a .prompt file with temperature: 0.7 or budget.cost: 5 cannot be cleared from the CLI, even though 0 is a valid documented value for both flags.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cli/ai_prompt_file.go` around lines 54 - 78, The overlayCLI logic is
dropping explicit zero values for temperature and budget because it treats 0 as
“not provided.” Update the merge behavior in overlayCLI so that CLI flags can
distinguish unset from an explicit 0 for o.Temperature and o.Budget, and then
apply 0 as a valid override to req.Model.Temperature and req.Budget.Cost instead
of falling back to saved or base values.
pkg/cli/ai_models.go-104-115 (1)

104-115: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

--backend gemini is documented here but rejected at runtime.

Line 70 advertises Gemini, and pkg/cli/configure.go already treats Gemini as a live API-backed model source, but this switch only allows OpenAI, Anthropic, or CLI backends. The result is a broken documented command path for captain ai models --backend gemini.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cli/ai_models.go` around lines 104 - 115, The backend validation in
runLiveModels is too restrictive and rejects the documented Gemini path. Update
the switch on backendFilter in runLiveModels so `--backend gemini` is accepted
alongside the existing live API-backed backends, and keep the error branch only
for truly unsupported values. Make sure the allowed-backend check stays
consistent with the CLI documentation and the backend handling used by
configure.go, while preserving the existing CLI/backend catalog shortcut.
pkg/cli/webapp/src/App.tsx-165-172 (1)

165-172: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard route decoding against malformed URLs.

Lines 167 and 171 can throw URIError on a bad % escape, which takes down the whole SPA instead of falling back to an invalid-route state.

💡 Suggested change
+function safeDecodeURIComponent(value: string) {
+  try {
+    return decodeURIComponent(value);
+  } catch {
+    return value;
+  }
+}
+
 function parseRoute(pathname: string, search: string): Route {
   if (pathname.startsWith("/operations")) return { kind: "operations" };
   if (pathname.startsWith("/sessions")) {
     const raw = pathname.slice("/sessions".length).replace(/^\/+/, "");
-    const sessionId = raw ? decodeURIComponent(raw.split("/")[0] ?? "") : undefined;
+    const sessionId = raw ? safeDecodeURIComponent(raw.split("/")[0] ?? "") : undefined;
     return sessionId ? { kind: "sessions", sessionId } : { kind: "sessions" };
   }
   if (pathname.startsWith("/chat/")) {
-    const threadId = decodeURIComponent(pathname.slice("/chat/".length).split("/")[0] ?? "");
+    const threadId = safeDecodeURIComponent(
+      pathname.slice("/chat/".length).split("/")[0] ?? "",
+    );
     const model = new URLSearchParams(search).get("model") || undefined;
     return threadId ? { kind: "chat", threadId, model } : { kind: "launcher" };
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cli/webapp/src/App.tsx` around lines 165 - 172, Guard the route parsing
in App.tsx so malformed percent-escapes do not throw during navigation. In the
route handling logic that builds the sessions and chat route objects, wrap the
decodeURIComponent calls used for sessionId and threadId in a safe helper or
try/catch and fall back to an invalid-route state when decoding fails. Keep the
fix localized to the pathname parsing branch in App’s route resolver so bad URLs
no longer crash the SPA.
.gitignore-14-16 (1)

14-16: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reorder the dist/ ignore and exception.

Line 16 re-ignores pkg/cli/webapp/dist/ after Lines 14-15 unignore it, so the exception never takes effect.

💡 Suggested change
 pkg/cli/webapp/node_modules/
 pkg/cli/webapp/*.tsbuildinfo
-!pkg/cli/webapp/dist/
-!pkg/cli/webapp/dist/**
 dist/
+!pkg/cli/webapp/dist/
+!pkg/cli/webapp/dist/**
 .grite/
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.gitignore around lines 14 - 16, The .gitignore rules for
pkg/cli/webapp/dist are ordered incorrectly, so the later dist/ ignore overrides
the earlier exception. Update the ignore entries so the generic dist/ rule is
placed before the pkg/cli/webapp/dist/ unignore patterns, and keep the related
exception entries together in the correct order to ensure the webapp dist
directory is actually tracked.
🧹 Nitpick comments (3)
pkg/ai/models_remote_test.go (1)

150-163: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the new cmux backends in the rejection matrix.

ListModels now rejects all non-API backends, but this test only pins the older CLI/agent set. Adding BackendClaudeCmux and BackendCodexCmux would keep the contract aligned with the expanded backend enum.

Suggested fix
-// TestListModels_RejectsCLIBackends pins that CLI/agent backends have no live
+// TestListModels_RejectsNonAPIBackends pins that non-API backends have no live
 // listing path: they authenticate internally and enumerate from the static
 // catalog (pkg/cli), so ListModels must fail loud rather than require an API key.
-func TestListModels_RejectsCLIBackends(t *testing.T) {
+func TestListModels_RejectsNonAPIBackends(t *testing.T) {
 	t.Setenv("OPENAI_API_KEY", "sk-test")
 	t.Setenv("ANTHROPIC_API_KEY", "ant-test")
 	t.Setenv("GEMINI_API_KEY", "g-test")

-	for _, b := range []Backend{BackendClaudeCLI, BackendClaudeAgent, BackendCodexCLI, BackendGeminiCLI} {
+	for _, b := range []Backend{
+		BackendClaudeCLI,
+		BackendClaudeAgent,
+		BackendCodexCLI,
+		BackendGeminiCLI,
+		BackendClaudeCmux,
+		BackendCodexCmux,
+	} {
 		if _, err := ListModels(context.Background(), b); err == nil {
-			t.Errorf("backend=%s: expected error (no live listing for CLI/agent backends)", b)
+			t.Errorf("backend=%s: expected error (no live listing for non-API backends)", b)
 		}
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/models_remote_test.go` around lines 150 - 163, The ListModels
rejection test only covers the older CLI/agent backends, so extend the backend
set in TestListModels_RejectsCLIBackends to also include BackendClaudeCmux and
BackendCodexCmux. Update the loop over Backend values in this test so it asserts
ListModels fails for every non-API backend now supported by the enum, keeping
the contract in sync with ListModels and the Backend constants.
pkg/cli/ai_filters_test.go (1)

68-77: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the model-completion test deterministic.

This assertion is pinned to whatever default aichat catalog ships today, so a dependency bump can fail the test even if the prefix-stripping logic is still correct. pkg/cli/ai_catalog_test.go:15-28 already has the safer pattern: install a tiny synthetic catalog here and assert against that.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cli/ai_filters_test.go` around lines 68 - 77, The model suggestion test
is currently dependent on the external default aichat catalog, so it can break
on dependency changes even when the prefix-stripping behavior is correct. Update
TestAIFilters_ModelSuggestsBareNames in ai_filters_test.go to use a small
synthetic catalog like the pattern already used in ai_catalog_test.go, then
assert the bare-name output against that fixed data via optionKeys and contains
instead of relying on whatever catalog ships by default.
pkg/cli/webapp/src/SessionBrowser.tsx (1)

58-65: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Debounce the session search before refetching.

Line 63 ties the request directly to the raw input value, so each keystroke kicks off a new session scan. A short debounce will make the filter feel much cheaper.

💡 Suggested change
   const [source, setSource] = useState<SourceFilter>("all");
   const [allProjects, setAllProjects] = useState(false);
   const [query, setQuery] = useState("");
+  const [debouncedQuery, setDebouncedQuery] = useState("");
+
+  useEffect(() => {
+    const timer = window.setTimeout(() => setDebouncedQuery(query), 250);
+    return () => window.clearTimeout(timer);
+  }, [query]);
 
   const listQuery = useQuery({
-    queryKey: ["sessions", source, allProjects, query],
-    queryFn: () => fetchSessions({ source, allProjects, query }),
+    queryKey: ["sessions", source, allProjects, debouncedQuery],
+    queryFn: () => fetchSessions({ source, allProjects, query: debouncedQuery }),
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/cli/webapp/src/SessionBrowser.tsx` around lines 58 - 65, The session
search is refetching on every keystroke because the raw query state is used
directly in SessionBrowser’s useQuery and fetchSessions call. Introduce a
debounced version of the query input in SessionBrowser, and use that debounced
value in the queryKey and queryFn so fetchSessions only runs after typing
pauses. Keep the existing source and allProjects filters unchanged, and update
the query state handling so the UI still reflects the immediate input while the
network request uses the delayed value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ad47f855-3c7d-47ac-a338-01017fc8519f

📥 Commits

Reviewing files that changed from the base of the PR and between 0fc0b94 and 1477665.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (127)
  • .gitignore
  • README.md
  • cmd/captain/main.go
  • go.mod
  • pkg/ai/agent.go
  • pkg/ai/agent/runner.go
  • pkg/ai/agent/runner_test.go
  • pkg/ai/agent/scope_test.go
  • pkg/ai/agent_test.go
  • pkg/ai/client.go
  • pkg/ai/history/codex_parser.go
  • pkg/ai/history/codex_parser_test.go
  • pkg/ai/loop_test.go
  • pkg/ai/middleware/caching.go
  • pkg/ai/middleware/cost.go
  • pkg/ai/middleware/logging.go
  • pkg/ai/middleware/logging_test.go
  • pkg/ai/models_remote.go
  • pkg/ai/models_remote_test.go
  • pkg/ai/pricing/registry.go
  • pkg/ai/pricing/registry_test.go
  • pkg/ai/prompt/prompt.go
  • pkg/ai/prompt/prompt_test.go
  • pkg/ai/prompt/testdata/fixtures/anthropic-claude-opus.prompt
  • pkg/ai/prompt/testdata/fixtures/anthropic-claude-sonnet.prompt
  • pkg/ai/prompt/testdata/fixtures/claude-agent-opus.prompt
  • pkg/ai/prompt/testdata/fixtures/claude-agent-sonnet.prompt
  • pkg/ai/prompt/testdata/fixtures/claude-cli-opus.prompt
  • pkg/ai/prompt/testdata/fixtures/claude-cli-sonnet.prompt
  • pkg/ai/prompt/testdata/fixtures/codex-cli.prompt
  • pkg/ai/prompt/testdata/fixtures/gemini-api.prompt
  • pkg/ai/prompt/testdata/fixtures/gemini-cli.prompt
  • pkg/ai/prompt/testdata/fixtures/openai-gpt.prompt
  • pkg/ai/prompt/testdata/options.prompt
  • pkg/ai/provider.go
  • pkg/ai/provider/claudeagent/provider.go
  • pkg/ai/provider/claudeagent/provider_test.go
  • pkg/ai/provider/claudeagent/turn.go
  • pkg/ai/provider/cli.go
  • pkg/ai/provider/cli_test.go
  • pkg/ai/provider/cmux/client.go
  • pkg/ai/provider/cmux/client_test.go
  • pkg/ai/provider/cmux/executor.go
  • pkg/ai/provider/cmux/executor_test.go
  • pkg/ai/provider/cmux/focus.go
  • pkg/ai/provider/cmux/provider.go
  • pkg/ai/provider/cmux/provider_test.go
  • pkg/ai/provider/cmux/sessionlog.go
  • pkg/ai/provider/cmux/sessionlog_test.go
  • pkg/ai/provider/cmux/sessionstats.go
  • pkg/ai/provider/cmux/sessionstats_test.go
  • pkg/ai/provider/cmux/stall.go
  • pkg/ai/provider/cmux/stall_test.go
  • pkg/ai/provider/cmux/submit.go
  • pkg/ai/provider/cmux/submit_test.go
  • pkg/ai/provider/cmux/support_test.go
  • pkg/ai/provider/codex_appserver.go
  • pkg/ai/provider/codex_appserver_protocol.go
  • pkg/ai/provider/codex_appserver_test.go
  • pkg/ai/provider/gemini_cli.go
  • pkg/ai/provider/genkit/genkit.go
  • pkg/ai/provider/genkit/genkit_test.go
  • pkg/ai/provider/genkit/options.go
  • pkg/ai/provider/init.go
  • pkg/ai/provider_test.go
  • pkg/ai/typed.go
  • pkg/ai/types.go
  • pkg/api/budget.go
  • pkg/api/context.go
  • pkg/api/cost.go
  • pkg/api/cost_test.go
  • pkg/api/enums.go
  • pkg/api/enums_test.go
  • pkg/api/memory.go
  • pkg/api/model.go
  • pkg/api/permissions.go
  • pkg/api/pretty.go
  • pkg/api/pretty_test.go
  • pkg/api/prompt.go
  • pkg/api/schema.go
  • pkg/api/schema_test.go
  • pkg/api/spec.go
  • pkg/api/spec_test.go
  • pkg/claude/session.go
  • pkg/claude/session_test.go
  • pkg/claude/tooluse.go
  • pkg/claude/tooluse_test.go
  • pkg/cli/ai.go
  • pkg/cli/ai_agent.go
  • pkg/cli/ai_agent_test.go
  • pkg/cli/ai_catalog.go
  • pkg/cli/ai_catalog_test.go
  • pkg/cli/ai_filters.go
  • pkg/cli/ai_filters_test.go
  • pkg/cli/ai_models.go
  • pkg/cli/ai_prompt_file.go
  • pkg/cli/ai_prompt_file_test.go
  • pkg/cli/ai_test.go
  • pkg/cli/chat_thread_store.go
  • pkg/cli/chat_thread_store_test.go
  • pkg/cli/configure.go
  • pkg/cli/configure_test.go
  • pkg/cli/cost.go
  • pkg/cli/history.go
  • pkg/cli/info.go
  • pkg/cli/serve.go
  • pkg/cli/serve_test.go
  • pkg/cli/session_filter.go
  • pkg/cli/session_filter_test.go
  • pkg/cli/sessions.go
  • pkg/cli/sessions_test.go
  • pkg/cli/stdin.go
  • pkg/cli/webapp/index.html
  • pkg/cli/webapp/src/AgentLauncher.tsx
  • pkg/cli/webapp/src/App.tsx
  • pkg/cli/webapp/src/ChatLayer.tsx
  • pkg/cli/webapp/src/ChatRoute.tsx
  • pkg/cli/webapp/src/SessionBrowser.tsx
  • pkg/cli/webapp/src/api.ts
  • pkg/cli/webapp/src/index.css
  • pkg/cli/webapp/src/main.tsx
  • pkg/cli/webapp/src/session.ts
  • pkg/cli/webapp/tsconfig.json
  • pkg/cli/webapp/vite.config.ts
  • pkg/cli/whoami.go
  • pkg/cli/whoami_render.go
  • pkg/cli/whoami_test.go

Comment on lines +236 to +289
func AgentCommand(opts AgentCommandOpts) string {
switch opts.Agent {
case "codex":
if opts.Model != "" {
return "codex -m " + opts.Model
}
return "codex"
default:
cmd := "claude"
if opts.SessionID != "" {
if opts.Resume {
cmd += " --resume " + opts.SessionID
} else {
cmd += " --session-id " + opts.SessionID
}
}
mode := opts.PermissionMode
if opts.Plan {
mode = api.PermissionPlan
}
if opts.Plan || opts.PermissionMode != "" {
cmd += " --permission-mode " + cliPermissionMode(mode)
}
if len(opts.AllowedTools) > 0 {
cmd += " --allowedTools " + strings.Join(opts.AllowedTools, ",")
}
if len(opts.DisallowedTools) > 0 {
cmd += " --disallowedTools " + strings.Join(opts.DisallowedTools, ",")
}
if opts.Model != "" {
cmd += " --model " + opts.Model
}
return cmd
}
}

// withEnv prepends KEY='value' assignments (sorted by key for deterministic
// output) to the agent launch command so the terminal shell exports them to the
// agent process, whose tool children inherit them. The host supplies the env via
// req.Context.Env.
func withEnv(command string, env map[string]string) string {
if len(env) == 0 {
return command
}
keys := make([]string, 0, len(env))
for k := range env {
keys = append(keys, k)
}
sort.Strings(keys)
assigns := make([]string, 0, len(keys))
for _, k := range keys {
assigns = append(assigns, k+"="+shellSingleQuote(env[k]))
}
return strings.Join(assigns, " ") + " " + command

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Escape or validate every dynamic shell token before pasting this command.

AgentCommand() concatenates SessionID, Model, and tool lists directly into a shell command, and withEnv() only quotes values while leaving env keys unvalidated. This string is sent to an interactive shell, so a repo-controlled model/frontmatter value or caller-supplied env key like A=1; touch /tmp/pwned turns into local command injection before the agent even starts.

Possible fix
+var safeEnvKey = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
+
+func shellArg(v string) string {
+	return shellSingleQuote(v)
+}
+
 func AgentCommand(opts AgentCommandOpts) string {
 	switch opts.Agent {
 	case "codex":
-		if opts.Model != "" {
-			return "codex -m " + opts.Model
+		parts := []string{"codex"}
+		if opts.Model != "" {
+			parts = append(parts, "-m", shellArg(opts.Model))
 		}
-		return "codex"
+		return strings.Join(parts, " ")
 	default:
-		cmd := "claude"
+		parts := []string{"claude"}
 		if opts.SessionID != "" {
 			if opts.Resume {
-				cmd += " --resume " + opts.SessionID
+				parts = append(parts, "--resume", shellArg(opts.SessionID))
 			} else {
-				cmd += " --session-id " + opts.SessionID
+				parts = append(parts, "--session-id", shellArg(opts.SessionID))
 			}
 		}
-
-		return cmd
+
+		return strings.Join(parts, " ")
 	}
 }
 
 func withEnv(command string, env map[string]string) string {
 	…
 	for _, k := range keys {
+		if !safeEnvKey.MatchString(k) {
+			continue
+		}
 		assigns = append(assigns, k+"="+shellSingleQuote(env[k]))
 	}
 	return strings.Join(assigns, " ") + " " + command
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/ai/provider/cmux/executor.go` around lines 236 - 289, The shell command
builder in AgentCommand and withEnv is concatenating untrusted values directly
into a command string, which can allow command injection. Update AgentCommand to
escape or validate every dynamic token it appends, including SessionID, Model,
AllowedTools, DisallowedTools, and any permission-related values, and ensure
withEnv also rejects or sanitizes unsafe env keys before prefixing assignments.
Use the existing AgentCommand and withEnv helpers as the place to centralize
safe shell quoting/validation so the final launch command cannot be influenced
by raw shell metacharacters.

Comment thread pkg/cli/serve.go Outdated
moshloop added 5 commits June 30, 2026 08:30
…built webapp

The //go:embed webapp/dist directive failed to compile in CI (build, test, and
lint jobs) because dist is gitignored and the webapp cannot be built in CI (it
depends on a local clicky-ui link). Commit a tracked dist/.gitkeep placeholder
and switch to //go:embed all:webapp/dist so the directive always matches. The
real webapp overlays dist when built locally or in the release workflow.
…of inline truncation

Gavel-Issue-Id: aac4545f57ee05bb834447a3dad121aa
Claude-Session-Id: 327b3a33-9f27-4db0-97f2-675dbcd5c55c
…plan-mode plans

Gavel-Issue-Id: 55471644bcb5b00631cb9c4d70d0fe29
Claude-Session-Id: 0355fe0b-55fe-4391-b44d-c5897c93e931
@moshloop moshloop enabled auto-merge (rebase) June 30, 2026 06:26
moshloop added 6 commits June 30, 2026 10:59
Move the provider runtime contract (Config, Provider/StreamingProvider, Response,
Event/EventKind/Event*, PermissionFunc/Request/Decision, the provider registry and
NewProvider) from pkg/ai into pkg/api so external consumers (clicky/aichat) can
depend on the stable api surface without importing the heavier pkg/ai runtime.
pkg/ai re-exports every moved symbol as a type alias / const / wrapper func, so all
existing call sites and pkg/ai/provider registration compile unchanged. Additive to
pkg/api (backwards compatible); the registry map stays unexported.
Bump clicky/aichat to v1.21.34-0.20260630075958-cd55f2f4fce3, the commit where
aichat depends on captain/pkg/api (the relocated runtime contract) instead of
pkg/ai. Resolves the CI build failure: captain (the main module) satisfies
aichat's captain imports from its own pkg/api source.
This implements a comprehensive model catalog system with support for both API and agent backends. The catalog manages model registration, lookup, and resolution with live model fetching, pricing integration, and caching.

Key features:
- Model registry with in-place updates and safe concurrent access
- Resolution pipeline that merges catalog entries with live API listings
- Pricing integration supporting both OpenRouter and static Claude pricing tables
- Model cache with 24-hour TTL and fingerprint-based invalidation
- JSON API for client-driven model selection with configurability detection
- Comprehensive test coverage for all catalog operations
runCLI read StdoutPipe/StderrPipe in goroutines that raced cmd.Wait(); Wait closes
those pipes on process exit, so a Wait-first ordering failed an in-flight read with
'file already closed' (deterministic on Linux CI, passing on macOS). Use bytes.Buffer
for cmd.Stdout/Stderr so Wait blocks until the output copiers finish, and feed stdin
from a goroutine. Fixes TestRunCLIUsesContextDir on Linux.
Three defects in the model-catalog work surfaced once CI could compile:
- catalog_resolve_test.go: the three live-fetch tests set ANTHROPIC_API_KEY
  before stubLiveFetcher, which clears all keys, so the live fetch was skipped.
  Set the key after the stub (matching TestResolveModels_TokensUnionDedup).
- catalog_resolve.go: use the promoted ResolvedModel.ContextWindow (staticcheck QF1008).
- cmux/sessionstats.go: modelInfo stopped at the first candidate, but the bare
  hyphenated id classifies to the static Claude table (prices only, zero context),
  shadowing the dotted OpenRouter id that carries the context window. Prefer a
  candidate with a non-zero context window.
@moshloop moshloop merged commit 38298c2 into main Jun 30, 2026
11 checks passed
@moshloop moshloop deleted the feat/captain-subagent-history branch June 30, 2026 09:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants