diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c74e5910..91e50e1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,3 +69,27 @@ jobs: # Optional secrets used by some repos' goreleaser configs: HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + + # Sign release artifacts with cosign keyless signing. The + # id-token: write permission above enables OIDC federation with + # Fulcio's root CA — no long-lived signing keys to manage. + - name: Install cosign + uses: sigstore/cosign-installer@d8a3f51ef971a853e51de6231a89f4483c5a7a0e # v3.4.0 + + - name: Sign release checksums with cosign + run: | + set -euo pipefail + # Download the checksums artifact produced by goreleaser. + gh release download "${GITHUB_REF_NAME}" --pattern "checksums.txt" -D dist/ + # Sign with keyless OIDC — output is dist/checksums.txt.sig (bundle). + cosign sign-blob \ + --yes \ + --output-signature dist/checksums.txt.sig \ + --output-certificate dist/checksums.txt.cert \ + dist/checksums.txt + # Upload the signature and certificate back to the release. + gh release upload "${GITHUB_REF_NAME}" \ + dist/checksums.txt.sig \ + dist/checksums.txt.cert + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 3338d6f6..61861367 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -128,6 +128,12 @@ sboms: documents: - "${artifact}.spdx.sbom.json" +# --------------------------------------------------------------------------- +# Signing — cosign keyless signing of checksums is performed in the +# release workflow (post-goreleaser) so the OIDC token from GitHub Actions +# is available. The checksums.txt artifact is signed there. +# --------------------------------------------------------------------------- + release: draft: false prerelease: auto diff --git a/README.md b/README.md index 6f175b64..f468b259 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,35 @@ Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) for a smooth | **Code** | `LSP` diagnostics, `CodeSearch`, `NotebookEdit`, `SQL` (read-only DB exploration) | | **MCP** | `ListMcpResources`, `ReadMcpResource` | +### Portable Execution Graph + +Export the latest or a selected Hawk session as validated graph nodes, edges, +and lifecycle events: + +```bash +hawk graph export +hawk graph export +hawk graph export --trace-checkpoint abc123def456 +hawk graph export --mission-dir /path/to/mission + +# Explicitly privacy-normalize and sync the graph for a connected cloud project +hawk cloud graph sync +hawk cloud graph sync --mission-dir /path/to/mission +``` + +The export contains metadata and hashes, not prompts, tool arguments/results, +policy reasons, verification evidence, or runtime output. Trace remains +available separately as `hawk trace graph export`. Persisted chat sessions +automatically append privacy-safe permission, enabled approval-gate, and +`VerifyPlanExecution` summaries for subsequent graph exports. Yaad memory +subgraphs and Hawk code-index chunks actually selected for inference are also +journaled as metadata-only knowledge nodes and linked to the session. Inspect's +observed bridge path similarly journals bounded, metadata-only report/finding +quality subgraphs. Sight exposes the same observed bridge boundary for +metadata-only code-review quality subgraphs. Mission runs also persist a +portable `mission-graph.json`; the mission form is validated and synchronized +explicitly with the `--mission-dir` variants above. + ### Multi-Agent Mission Mode (optional) For larger tasks, decompose work into parallel feature branches (power-user / future team workflows): @@ -271,6 +300,7 @@ Endpoints: `GET /v1/health`, `GET /v1/ready` (dependency-aware readiness), `POST hawk mission "Add auth, rate limiting, and logging" hawk mission --workers 6 "Refactor into microservices" hawk mission --dry-run "What would this decompose into?" +hawk mission --from-tasks # Execute validated dependency waves ``` ## Providers @@ -375,12 +405,15 @@ You may keep a **personal** parent **`go.work`** that lists alternate clones on | **eyrie** | [GrayCodeAI/eyrie](https://github.com/GrayCodeAI/eyrie) | LLM provider runtime | | **sight** | [GrayCodeAI/sight](https://github.com/GrayCodeAI/sight) | Diff-based code review (`hawk sight`) | | **inspect** | [GrayCodeAI/inspect](https://github.com/GrayCodeAI/inspect) | Site audit library | -| **tok** | [GrayCodeAI/tok](https://github.com/GrayCodeAI/tok) | Tokenizer & compression | +| **tok** | [GrayCodeAI/tok](https://github.com/GrayCodeAI/tok) | Compression, redaction, token/cost budgets, and privacy-safe runtime graph facts | | **yaad** | [GrayCodeAI/yaad](https://github.com/GrayCodeAI/yaad) | Graph-based memory | | **trace** | [GrayCodeAI/trace](https://github.com/GrayCodeAI/trace) | Session capture and replay engine mounted as `hawk trace ...` | | **hawk-core-contracts** | [GrayCodeAI/hawk-core-contracts](https://github.com/GrayCodeAI/hawk-core-contracts) | Shared contracts and neutral cross-repo vocabulary | For the consolidated repo map and the current-vs-proposed architecture diagrams, see [docs/architecture/hawk-current-vs-proposed.md](docs/architecture/hawk-current-vs-proposed.md). +For execution-graph ownership, automatic capture seams, export/sync commands, +and the Trace correlation contract, see +[docs/architecture/execution-graph.md](docs/architecture/execution-graph.md). ## Development diff --git a/api/openapi.yaml b/api/openapi.yaml index 43566705..e8bf6150 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -125,6 +125,154 @@ components: tool_calls: type: integer + GraphScope: + type: object + properties: + tenant_id: + type: string + project_id: + type: string + repository_id: + type: string + + GraphRef: + type: object + required: [kind, id] + properties: + kind: + type: string + enum: [system, knowledge, execution, policy, quality, operations] + id: + type: string + + GraphArtifactRef: + type: object + required: [uri] + properties: + uri: + type: string + digest: + type: string + media_type: + type: string + + GraphProvenance: + type: object + required: [producer] + properties: + producer: + type: string + version: + type: string + source_id: + type: string + evidence: + type: array + items: + $ref: "#/components/schemas/GraphArtifactRef" + + GraphNode: + type: object + required: [id, kind, created_at, provenance] + properties: + id: + type: string + kind: + type: string + enum: [system, knowledge, execution, policy, quality, operations] + scope: + $ref: "#/components/schemas/GraphScope" + created_at: + type: string + format: date-time + effective_at: + type: string + format: date-time + provenance: + $ref: "#/components/schemas/GraphProvenance" + attributes: + type: object + additionalProperties: + type: string + + GraphEdge: + type: object + required: [id, kind, from, to, created_at, provenance] + properties: + id: + type: string + kind: + type: string + enum: [contains, depends_on, references, produced, governed_by, validated_by] + from: + $ref: "#/components/schemas/GraphRef" + to: + $ref: "#/components/schemas/GraphRef" + scope: + $ref: "#/components/schemas/GraphScope" + created_at: + type: string + format: date-time + effective_at: + type: string + format: date-time + provenance: + $ref: "#/components/schemas/GraphProvenance" + attributes: + type: object + additionalProperties: + type: string + + GraphEvent: + type: object + required: [id, type, subject, occurred_at, provenance] + properties: + id: + type: string + type: + type: string + enum: [created, updated, transitioned, observed, deleted] + subject: + $ref: "#/components/schemas/GraphRef" + scope: + $ref: "#/components/schemas/GraphScope" + occurred_at: + type: string + format: date-time + correlation_id: + type: string + causation_id: + type: string + idempotency_key: + type: string + provenance: + $ref: "#/components/schemas/GraphProvenance" + + ExecutionGraph: + type: object + required: [schema_version, generated_at, scope, nodes, edges, events] + properties: + schema_version: + type: string + enum: [hawk.graph/v1] + generated_at: + type: string + format: date-time + scope: + $ref: "#/components/schemas/GraphScope" + nodes: + type: array + items: + $ref: "#/components/schemas/GraphNode" + edges: + type: array + items: + $ref: "#/components/schemas/GraphEdge" + events: + type: array + items: + $ref: "#/components/schemas/GraphEvent" + Message: type: object properties: @@ -259,6 +407,8 @@ tags: description: Session management - name: messages description: Message history + - name: graphs + description: Portable read-only execution graph projections - name: stats description: Usage statistics - name: review @@ -458,6 +608,72 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/sessions/{id}/graph: + get: + tags: [graphs] + summary: Project a persisted session as a portable execution graph + description: | + Returns Hawk's privacy-safe, read-only `hawk.graph/v1` projection. + Prompt text, tool arguments, tool output, and verification details are + excluded. Explicit Trace checkpoint IDs are additive to authoritative + Trace session correlation performed by Hawk. + parameters: + - name: id + in: path + required: true + schema: + type: string + maxLength: 128 + pattern: '^[A-Za-z0-9._-]+$' + - name: repository + in: query + description: Optional repository scope override. + schema: + type: string + maxLength: 256 + - name: trace_checkpoint + in: query + description: Optional 12-character lowercase hexadecimal Trace checkpoint ID. May be repeated up to 64 times. + schema: + type: array + maxItems: 64 + items: + type: string + pattern: '^[0-9a-f]{12}$' + style: form + explode: true + responses: + "200": + description: Portable execution graph + content: + application/json: + schema: + $ref: "#/components/schemas/ExecutionGraph" + "400": + description: Invalid session, repository, or checkpoint input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Session not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "503": + description: Graph projection is not configured + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/stats: get: tags: [stats] diff --git a/cmd/agent.go b/cmd/agent.go index 260aa292..1e26c13f 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -18,6 +19,8 @@ var agentCmd = &cobra.Command{ Long: "Create, list, and manage custom agent personas stored in Hawk user state.", } +var agentListJSON bool + var agentListCmd = &cobra.Command{ Use: "list", Short: "List all available agents", @@ -54,17 +57,29 @@ func init() { agentCreateCmd.Flags().StringVarP(&agentCreateDesc, "description", "d", "", "Agent description") agentCreateCmd.Flags().StringVarP(&agentCreateModel, "model", "m", "", "Model to use (empty = inherit)") + agentListCmd.Flags().BoolVar(&agentListJSON, "json", false, "output agents as JSON") agentCmd.AddCommand(agentListCmd) agentCmd.AddCommand(agentCreateCmd) agentCmd.AddCommand(agentShowCmd) agentCmd.AddCommand(agentRemoveCmd) } -func runAgentList(_ *cobra.Command, _ []string) error { +func runAgentList(cmd *cobra.Command, _ []string) error { all, err := agents.ListAll() if err != nil { return err } + if agentListJSON { + if all == nil { + all = []*agents.Agent{} + } + out, err := json.MarshalIndent(all, "", " ") + if err != nil { + return fmt.Errorf("marshaling agents: %w", err) + } + cmd.Println(string(out)) + return nil + } if len(all) == 0 { fmt.Printf("No agents found. Create one with: hawk agent create \n") fmt.Printf("Agent directory: %s\n", agents.DefaultDir()) @@ -80,7 +95,10 @@ func runAgentList(_ *cobra.Command, _ []string) error { } desc := a.Description if len(desc) > 50 { - desc = desc[:50] + "..." + // Rune-safe truncation: never split a multibyte UTF-8 sequence. + if runes := []rune(desc); len(runes) > 50 { + desc = string(runes[:50]) + "..." + } } _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", a.Name, model, desc) } diff --git a/cmd/agent_test.go b/cmd/agent_test.go new file mode 100644 index 00000000..ef4bad43 --- /dev/null +++ b/cmd/agent_test.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/hawk/internal/multiagent/agents" +) + +// sampleAgentMarkdown returns a minimal valid agent definition. +func sampleAgentMarkdown(name, description, model string) string { + return "---\nname: " + name + "\ndescription: " + description + "\nmodel: " + model + "\n---\n# " + name + "\n\nPrompt body for " + name + ".\n" +} + +func TestAgentList_JSON_Empty(t *testing.T) { + dir := t.TempDir() + t.Setenv("HAWK_STATE_DIR", filepath.Join(dir, "state")) + if err := os.MkdirAll(filepath.Join(dir, "state", "agents"), 0o755); err != nil { + t.Fatal(err) + } + + old := agentListJSON + t.Cleanup(func() { agentListJSON = old }) + agentListJSON = true + + var buf bytes.Buffer + agentListCmd.SetOut(&buf) + agentListCmd.SetErr(&buf) + + if err := agentListCmd.RunE(agentListCmd, nil); err != nil { + t.Fatalf("runAgentList returned error: %v", err) + } + + var decoded []*agents.Agent + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatalf("decoding JSON output %q: %v", buf.String(), err) + } + if decoded == nil { + t.Fatal("expected a JSON array (even if empty), got null") + } + if len(decoded) != 0 { + t.Fatalf("expected 0 agents, got %d", len(decoded)) + } +} + +func TestAgentList_JSON(t *testing.T) { + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + t.Setenv("HAWK_STATE_DIR", stateDir) + agentDir := filepath.Join(stateDir, "agents") + if err := os.MkdirAll(agentDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentDir, "worker.md"), []byte(sampleAgentMarkdown("worker", "Does work", "claude-sonnet-4-6")), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentDir, "reviewer.md"), []byte(sampleAgentMarkdown("reviewer", "Reviews code", "")), 0o644); err != nil { + t.Fatal(err) + } + + old := agentListJSON + t.Cleanup(func() { agentListJSON = old }) + agentListJSON = true + + var buf bytes.Buffer + agentListCmd.SetOut(&buf) + agentListCmd.SetErr(&buf) + + if err := agentListCmd.RunE(agentListCmd, nil); err != nil { + t.Fatalf("runAgentList returned error: %v", err) + } + + var decoded []*agents.Agent + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatalf("decoding JSON output %q: %v", buf.String(), err) + } + if len(decoded) != 2 { + t.Fatalf("expected 2 agents, got %d", len(decoded)) + } + found := map[string]*agents.Agent{} + for _, a := range decoded { + found[a.Name] = a + } + if found["worker"] == nil { + t.Fatal("missing worker agent in JSON output") + } + if found["worker"].Model != "claude-sonnet-4-6" { + t.Errorf("worker model = %q, want claude-sonnet-4-6", found["worker"].Model) + } + if found["reviewer"] == nil { + t.Fatal("missing reviewer agent in JSON output") + } + if found["reviewer"].Model != "" { + t.Errorf("reviewer model = %q, want empty (inherit)", found["reviewer"].Model) + } +} diff --git a/cmd/autocomplete.go b/cmd/autocomplete.go index 9184baca..a374ee22 100644 --- a/cmd/autocomplete.go +++ b/cmd/autocomplete.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + "github.com/mattn/go-runewidth" ) // Suggestion represents a single autocompletion suggestion. @@ -376,18 +378,18 @@ func FormatSuggestions(suggestions []Suggestion, maxDisplay int) string { suggestions = suggestions[:maxDisplay] } - // Find max text width for alignment + // Find max text width for alignment (display width, so multi-byte text aligns) maxWidth := 0 for _, s := range suggestions { - if len(s.Text) > maxWidth { - maxWidth = len(s.Text) + if w := runewidth.StringWidth(s.Text); w > maxWidth { + maxWidth = w } } var b strings.Builder for _, s := range suggestions { if s.Description != "" { - padding := strings.Repeat(" ", maxWidth-len(s.Text)+4) + padding := strings.Repeat(" ", maxWidth-runewidth.StringWidth(s.Text)+4) b.WriteString(fmt.Sprintf("%s%s%s\n", s.Text, padding, s.Description)) } else { b.WriteString(s.Text + "\n") diff --git a/cmd/autocomplete_test.go b/cmd/autocomplete_test.go index 96f1f2be..1a70b423 100644 --- a/cmd/autocomplete_test.go +++ b/cmd/autocomplete_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" "time" + + "github.com/mattn/go-runewidth" ) func TestCompleteSlashCommand(t *testing.T) { @@ -457,6 +459,26 @@ func TestFormatSuggestions(t *testing.T) { } } +// TestFormatSuggestions_MultibyteAlignment verifies that descriptions align to +// the same display column even when suggestion text contains multi-byte runes +// (byte length != display width for CJK/accented text). +func TestFormatSuggestions_MultibyteAlignment(t *testing.T) { + suggestions := []Suggestion{ + {Text: "你好", Description: "greeting"}, // 2 CJK runes = 4 display cells + {Text: "ab", Description: "letters"}, // 2 display cells + } + output := FormatSuggestions(suggestions, 10) + lines := strings.Split(strings.TrimRight(output, "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + col0 := runewidth.StringWidth(lines[0][:strings.Index(lines[0], "greeting")]) + col1 := runewidth.StringWidth(lines[1][:strings.Index(lines[1], "letters")]) + if col0 != col1 { + t.Errorf("descriptions misaligned: column %d vs %d\n%s", col0, col1, output) + } +} + func TestFormatSuggestionsMaxDisplay(t *testing.T) { suggestions := make([]Suggestion, 20) for i := range suggestions { diff --git a/cmd/autonomy_picker.go b/cmd/autonomy_picker.go index 965686be..daf52911 100644 --- a/cmd/autonomy_picker.go +++ b/cmd/autonomy_picker.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/mattn/go-runewidth" ) // autonomyPickerEntry is one selectable row in the picker. @@ -182,8 +183,8 @@ func (ap *AutonomyPicker) Render(viewWidth int) string { } else { nameWidth := 0 for _, e := range ap.filtered { - if len(e.Name) > nameWidth { - nameWidth = len(e.Name) + if w := runewidth.StringWidth(e.Name); w > nameWidth { + nameWidth = w } } for i, e := range ap.filtered { @@ -202,8 +203,9 @@ func (ap *AutonomyPicker) Render(viewWidth int) string { } func padRight(s string, width int) string { - if len(s) >= width { + w := runewidth.StringWidth(s) + if w >= width { return s } - return s + strings.Repeat(" ", width-len(s)) + return s + strings.Repeat(" ", width-w) } diff --git a/cmd/autonomy_picker_test.go b/cmd/autonomy_picker_test.go index 08e78040..fc06a75c 100644 --- a/cmd/autonomy_picker_test.go +++ b/cmd/autonomy_picker_test.go @@ -5,8 +5,40 @@ import ( tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/mattn/go-runewidth" ) +// TestPadRight_MultibyteDisplayWidth verifies that padRight pads to a target +// DISPLAY width (via runewidth), not byte length — so multi-byte names align +// correctly in the picker columns. +func TestPadRight_MultibyteDisplayWidth(t *testing.T) { + tests := []struct { + name string + input string + width int + }{ + {"ascii", "hello", 10}, + {"cjk", "你好", 10}, // 2 CJK chars = 4 display cells + {"mixed", "a你b", 12}, + {"exact", "hello", 5}, + {"overflow", "hello world", 5}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := padRight(tt.input, tt.width) + if runewidth.StringWidth(tt.input) >= tt.width { + if got != tt.input { + t.Errorf("padRight(%q, %d) = %q, want unchanged", tt.input, tt.width, got) + } + return + } + if gotW := runewidth.StringWidth(got); gotW != tt.width { + t.Errorf("padRight(%q, %d) display width = %d, want %d", tt.input, tt.width, gotW, tt.width) + } + }) + } +} + func TestAutonomyPicker_HasAllFiveTiers(t *testing.T) { ap := NewAutonomyPicker(80) if len(ap.entries) != 5 { diff --git a/cmd/autonomy_tiers.go b/cmd/autonomy_tiers.go index 1ff1ea88..a823291c 100644 --- a/cmd/autonomy_tiers.go +++ b/cmd/autonomy_tiers.go @@ -24,20 +24,20 @@ var containerAutonomyTierNames = []string{ "Autonomous", } -// DefaultContainerAutonomy is the tier applied when the sandbox becomes ready. +// DefaultContainerAutonomy is the tier applied when the Docker container becomes ready. const DefaultContainerAutonomy = engine.AutonomySemi // DefaultHostAutonomy is the tier applied when a session runs on the host -// (no container sandbox) and the user hasn't set an explicit autonomy level. +// (no Docker container) and the user hasn't set an explicit autonomy level. // Read-only tools (Glob/Read/Grep/LS/WebSearch/...) can't damage anything -// whether or not there's a sandbox, so there's no reason to prompt for them +// whether or not there's a container, so there's no reason to prompt for them // just because the container isn't available — only Write/Edit/Bash still ask. const DefaultHostAutonomy = engine.AutonomyBasic // applyDefaultHostAutonomy sets the host-mode default unless the user // already configured an explicit autonomy level (settings.json or a prior // SetAutonomy call). Mirrors the same Autonomy()==0 guard the container -// path uses when the sandbox becomes ready. +// path uses when the container becomes ready. func applyDefaultHostAutonomy(sess *engine.Session) { if sess != nil && sess.PermSvc().Autonomy() == 0 { sess.PermSvc().SetAutonomy(DefaultHostAutonomy) diff --git a/cmd/away_summary.go b/cmd/away_summary.go index a547737b..cd4ef8f4 100644 --- a/cmd/away_summary.go +++ b/cmd/away_summary.go @@ -37,7 +37,12 @@ func awaySummary(messages []displayMsg, lastActivity time.Time) string { switch msg.role { case "user": if len(msg.content) > 80 { - userTopics = append(userTopics, msg.content[:80]+"...") + topic := msg.content + // Rune-safe truncation: never split a multibyte UTF-8 sequence. + if runes := []rune(topic); len(runes) > 80 { + topic = string(runes[:80]) + "..." + } + userTopics = append(userTopics, topic) } else { userTopics = append(userTopics, msg.content) } diff --git a/cmd/bg_sessions.go b/cmd/bg_sessions.go index 62eeed42..48f9596c 100644 --- a/cmd/bg_sessions.go +++ b/cmd/bg_sessions.go @@ -129,7 +129,9 @@ func StartBGSession(prompt string, args []string) (*BGSessionInfo, error) { cmd := exec.CommandContext(context.Background(), "hawk", cmdArgs...) // #nosec G204 -- fixed command 'hawk' relaunching self with internal flags cmd.Dir = cwd - logF, err := os.Create(logFile) // #nosec G304 -- logFile built from internal bg-sessions directory and generated id + // 0600: the log captures full session output (private user state, matching + // the 0600 bg-session info JSON); os.Create would leave it group/world-readable. + logF, err := os.OpenFile(logFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) // #nosec G304 -- logFile built from internal bg-sessions directory and generated id if err != nil { return nil, fmt.Errorf("create log file: %w", err) } @@ -174,8 +176,8 @@ func FormatBGSessions(sessions []*BGSessionInfo) string { shortID = shortID[:8] } preview := s.Prompt - if len(preview) > 50 { - preview = preview[:50] + "..." + if runes := []rune(preview); len(runes) > 50 { + preview = string(runes[:50]) + "..." } age := time.Since(s.StartedAt).Round(time.Minute) b.WriteString(fmt.Sprintf(" [%s] %s — %s\n", shortID, s.Status, preview)) @@ -206,7 +208,11 @@ Examples: cmd.Printf("Background session started: %s (PID %d)\n", info.ID, info.PID) cmd.Printf("View logs: tail -f %s\n", info.LogFile) - cmd.Printf("Attach: hawk attach %s\n", info.ID[:8]) + attachID := info.ID + if len(attachID) > 8 { + attachID = attachID[:8] + } + cmd.Printf("Attach: hawk attach %s\n", attachID) return nil }, } @@ -250,6 +256,8 @@ var attachCmd = &cobra.Command{ }, } +var sessionsJSONFlag bool + var sessionsLsCmd = &cobra.Command{ Use: "ls", Short: "List background sessions", @@ -258,6 +266,14 @@ var sessionsLsCmd = &cobra.Command{ if err != nil { return err } + if sessionsJSONFlag { + out, err := json.MarshalIndent(sessions, "", " ") + if err != nil { + return fmt.Errorf("marshaling sessions: %w", err) + } + cmd.Println(string(out)) + return nil + } cmd.Println(FormatBGSessions(sessions)) return nil }, @@ -280,6 +296,7 @@ var sessionsKillCmd = &cobra.Command{ func init() { sessionsCmd.AddCommand(sessionsLsCmd, sessionsKillCmd) + sessionsLsCmd.Flags().BoolVar(&sessionsJSONFlag, "json", false, "output sessions as JSON") rootCmd.AddCommand(bgCmd) rootCmd.AddCommand(attachCmd) } diff --git a/cmd/chat.go b/cmd/chat.go index 821901bc..cd5d382b 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -46,11 +46,14 @@ import ( // Tool-registry construction (essential/optional tools) is in chat_tools.go // The Bubble Tea event loop (Update, applyPromptArrowKey) is in chat_update.go -const workInputPlaceholder = `Ask Hawk to inspect, edit, or run something... (Shift+Enter for newline)` +const workInputPlaceholder = `Ask Hawk to inspect, edit, or run something... (Shift+Enter for newline, ? for help)` func genID() string { b := make([]byte, 8) - _, _ = cryptorand.Read(b) + if _, err := cryptorand.Read(b); err != nil { + // CSPRNG failed — fall back to timestamp-based ID to avoid all-zeros. + return fmt.Sprintf("%016x", time.Now().UnixNano()) + } return fmt.Sprintf("%x", b) } @@ -59,6 +62,10 @@ func prepareSession(sess *engine.Session) (string, *session.Session, error) { if sessionIDFlag != "" && resumeID == "" && !continueFlag { id = sessionIDFlag } + if sessionIDFlag != "" && (resumeID != "" || continueFlag) { + // --session-id is ignored when --resume or --continue is also given. + fmt.Fprintf(os.Stderr, "hawk: --session-id ignored during resume/continue\n") + } if resumeID == "" && !continueFlag { return id, nil, nil } @@ -69,6 +76,8 @@ func prepareSession(sess *engine.Session) (string, *session.Session, error) { ) if resumeID != "" { saved, _, err = session.ResumeSession(resumeID) + // Second return value (recovery note) is intentionally unused: + // /recover command handles listing; here we just need the session. } else { cwd, _ := os.Getwd() saved, err = session.LoadLatestForCWD(cwd) @@ -184,7 +193,9 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco vp := viewport.New(viewport.WithWidth(initWidth), viewport.WithHeight(minChatViewportLines)) now := time.Now() - m := chatModel{input: ta, configInput: ci, spinner: sp, viewport: vp, session: sess, registry: registry, settings: settings, ref: ref, sessionID: sid, partial: &strings.Builder{}, spinnerVerb: spinnerVerbs[rand.Intn(len(spinnerVerbs))], width: initWidth, height: initHeight, historyIdx: 0, autoScroll: true, streamFollow: true, uiFocus: focusPrompt, startedAt: now, sessionStartedAt: now, activeSkills: make(map[string]plugin.SmartSkill)} // #nosec G404 -- non-cryptographic use (random spinner verb selection) + // Create a cancel function for background goroutines that gets called on quit. + _, bgCancel := context.WithCancel(context.Background()) + m := chatModel{input: ta, configInput: ci, spinner: sp, viewport: vp, session: sess, registry: registry, settings: settings, ref: ref, sessionID: sid, partial: &strings.Builder{}, spinnerVerb: spinnerVerbs[rand.Intn(len(spinnerVerbs))], width: initWidth, height: initHeight, historyIdx: 0, autoScroll: true, streamFollow: true, uiFocus: focusPrompt, startedAt: now, sessionStartedAt: now, activeSkills: make(map[string]plugin.SmartSkill), toolResultExpanded: make(map[int]bool), bgCancel: bgCancel} // #nosec G404 -- non-cryptographic use (random spinner verb selection) applyLiveModelMetadata(sess, effectiveProvider, effectiveModel) startup.MarkPhase("newChatModel:commandPalette") @@ -210,17 +221,32 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco if noContainer { m.messages = append(m.messages, displayMsg{ role: "system", - content: "--no-container runs tools on the host without sandbox isolation. " + - "Use default container mode for safer agent execution.", + content: "--no-container runs tools on the host without Docker container isolation. " + + "Use container mode for safer agent execution.", }) } } + // Surface startup warnings (missing API key, network, sessions dir). + // validateStartup is fully implemented but was previously never called. + if warnings := validateStartup(settings); len(warnings) > 0 { + var warnText strings.Builder + warnText.WriteString("Startup check:\n") + for _, w := range warnings { + warnText.WriteString(" ! " + w.Message + "\n") + } + m.messages = append(m.messages, displayMsg{role: "warning", content: warnText.String()}) + } + + // Set initial input placeholder based on mode. + m.refreshInputPlaceholder() + // Initialize lacy-inspired features startup.MarkPhase("newChatModel:lacy-features") m.termCtx = sessioncapture.NewTerminalContext() m.inputIndicator = &InputIndicator{} m.ghostText = NewGhostText() + m.contextualHelp = NewContextualHelp() m.modeManager = shellmode.NewModeManager() m.modeManager.LoadPersistedMode() m.brailleSpinner = NewBrailleSpinner(SpinnerHawk, "") @@ -290,7 +316,7 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco quickSnapshot := welcomeStatusSnapshot{} m.welcomeSetupState = quickSnapshot.setup m.welcomeAgentsOK = quickSnapshot.agentsOK - m.welcomeCache = buildWelcomeMessageWithSnapshot(sess, sid, registry, saved, settings, len(pr.SmartSkills), false, initWidth, initHeight, nil, quickSnapshot) + m.welcomeCache = buildWelcomeMessageWithSnapshot(sess, sid, registry, saved, settings, len(pr.SmartSkills), false, initWidth, initHeight, nil, quickSnapshot, false, "") m.messages = append(m.messages, displayMsg{role: "welcome", content: m.welcomeCache}) startup.EndPhase("newChatModel:welcome") @@ -382,6 +408,8 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco go func() { runtime := plugin.NewRuntime() if err := runtime.LoadAll(); err != nil { + // Surface plugin load failure so users know plugins are missing. + fmt.Fprintf(os.Stderr, "Warning: failed to load plugins: %v\n", err) return } runtime.RegisterHooks() @@ -467,6 +495,18 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco return m, nil } +// refreshInputPlaceholder updates the input placeholder based on the current +// execution mode (container vs host). Call this after state changes that +// affect the placeholder. +func (m *chatModel) refreshInputPlaceholder() { + base := "Ask Hawk to inspect, edit, or run something..." + if m.containerEnabled { + m.input.Placeholder = base + " (container mode, Shift+Enter for newline, ? for help)" + } else { + m.input.Placeholder = base + " (host mode, Shift+Enter for newline, ? for help)" + } +} + func (m chatModel) Init() tea.Cmd { cmds := []tea.Cmd{initTerminalMouseCmd(m.mouseEnabled()), promptKeepAliveCmd()} if gw, _ := m.sessionGatewayModel(); strings.TrimSpace(gw) != "" { @@ -597,9 +637,14 @@ func runChat() error { m.waiting = true } - p := tea.NewProgram(m, chatProgramOptions(m.mouseEnabled())...) // Suppress library log output (e.g. eyrie retry warnings) from corrupting the TUI. + // Must be set BEFORE tea.NewProgram so no initialization logs leak through. log.SetOutput(io.Discard) + + p := tea.NewProgram(m) + + // Enable terminal tab progress bar (OSC 9;4) for long-running operations. + EnableTabProgress() ref.Set(p) go func() { diff --git a/cmd/chat_commands.go b/cmd/chat_commands.go index 691f9950..4d8e2590 100644 --- a/cmd/chat_commands.go +++ b/cmd/chat_commands.go @@ -7,6 +7,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" tea "charm.land/bubbletea/v2" @@ -80,17 +81,26 @@ var allSlashCommands = []string{ "/run", "/btw", "/brainstorm", "/checkpoint", "/dream", "/away", "/investigate", "/search", "/security-review", "/session", "/share", "/skills", "/snapshot", "/soul", "/spec", "/stale", "/stats", "/mouse", "/select", "/status", "/statusline", "/summary", "/tag", "/taste", "/tasks", "/test", "/theme", "/think", "/thinkback", "/thinkback-play", "/tokens", "/tools", "/ultrareview", "/undo", "/upgrade", "/usage", "/version", "/vibe", "/vim", "/voice", "/welcome", "/ecosystem", "/path", "/yaad", + "/scroll-speed", "/scroll-invert", "/scroll-mode", "/terminal-setup", "/pager-config", "/prompt-queue", } func (m *chatModel) slashSuggestionsFor(input string) []string { - if input == m.slashSugInput { + if input == m.slashSugInput && m.slashSugGen == m.slashSugCachedGen { return m.slashSugCache } m.slashSugInput = input + m.slashSugCachedGen = m.slashSugGen m.slashSugCache = slashSuggestions(input) return m.slashSugCache } +// invalidateSlashSugCache bumps the generation counter so the next +// slashSuggestionsFor call recomputes suggestions. Call this when the +// command set may have changed (e.g. new messages, plugin reload). +func (m *chatModel) invalidateSlashSugCache() { + m.slashSugGen++ +} + // slashMenuOpen is true while the / command picker is visible (Cursor hides the footer then). func (m *chatModel) slashMenuOpen() bool { return len(m.slashSuggestionsFor(m.input.Value())) > 0 @@ -156,123 +166,140 @@ func slashAliases() map[string]string { } var slashDescriptions = map[string]string{ - "/add": "Add files to conversation context", - "/add-dir": "Add a directory to context", - "/agents": "List active agents", - "/agents-init": "Generate AGENTS.md from project template", - "/audit": "Show tool audit summary", - "/autonomy": "Autonomy Center for trust tier, sandbox, and rules", - "/branch": "Show git branch info", - "/btw": "Side note without triggering a response", - "/bughunter": "Hunt for bugs in the codebase", - "/check": "Review diff, find issues, auto-fix safe ones, verify before ship", - "/design": "Build or improve UI — use /design screenshot|system|component|regress for advanced modes", - "/hunt": "Diagnose root cause of errors before fixing (Waza method)", - "/think": "Turn rough idea into approved plan before coding (Waza method)", - "/clean": "Delete old sessions", - "/clear": "Clear conversation", - "/color": "Change agent color", - "/commit": "Auto-commit changes with AI message", - "/compact": "Compress conversation to save tokens", - "/compress": "Compress old sessions", - "/config": "Open settings panel", - "/context": "Show current context", - "/copy": "Copy chat or input to clipboard (/copy all|input|last|assistant)", - "/cost": "Show token usage and cost", - "/council": "Run LLM Council (multi-model consensus)", - "/diff": "Show git diff (preview changes)", - "/doctor": "Run diagnostics (build, test, lint)", - "/drop": "Remove file from context", - "/effort": "Set reasoning effort level", - "/glm": "Toggle GLM/Z.ai extended reasoning (on|off|default)", - "/env": "Show environment info", - "/exit": "Save and exit", - "/explain": "Trace code back to the commit that created it", - "/export": "Export session", - "/follow": "Toggle stream follow (auto-scroll)", - "/home": "Jump to top of chat and welcome header", - "/feedback": "Submit feedback about hawk", - "/fast": "Toggle fast mode", - "/files": "Show modified files", - "/focus": "Narrow agent attention to specific files/dirs", - "/fork": "Fork conversation to try a different approach", - "/branches": "List or switch conversation branches", - "/help": "Show all commands", - "/history": "List saved sessions", - "/hooks": "Show configured hooks", - "/init": "Analyze project structure", - "/integrity": "Validate session integrity", - "/lint": "Run linter, add issues to context", - "/loop": "Schedule recurring command", - "/mcp": "Show MCP server status", - "/memory": "Show AGENTS.md project instructions", - "/metrics": "Show session metrics", - "/model": "Switch or view current model", - "/new": "Start a fresh session", - "/pin": "Pin last N messages to protect from compaction", - "/parallel": "Run N agents in parallel on independent tasks", - "/plugins": "List installed plugins", - "/power": "Set power level (1-10)", - "/quit": "Save and exit", - "/recover": "Scan for interrupted sessions and resume", - "/refactor": "Agent-driven refactoring: dedup, dead code, lint fixes", - "/resume": "Resume a saved session", - "/retry": "Redo last message", - "/review": "Code review for bugs and issues", - "/rewind": "Undo last exchange", - "/run": "Run command, add output to context", - "/search": "Search across sessions", - "/select": "Pause TUI for native text selection (Ctrl+\\)", - "/mouse": "Toggle TUI mouse capture for native click-drag copy", - "/snapshot": "Manage file snapshots: list, restore , diff ", - "/stale": "Show stale rules that may need updating or removal", - "/security-review": "Security audit", - "/skills": "List skills or manage: search, install, trending, info, remove, update, feedback, publish, audit", - "/learn": "LLM-powered skill advisor (/learn deep for source analysis)", - "/stats": "Show analytics stats", - "/status": "Show session info", - "/summary": "Summarize the session", - "/tasks": "Show task list", - "/test": "Run tests, add failures to context", - "/tokens": "Show token estimate", - "/tools": "List enabled tools", - "/undo": "Undo the most recent file change", - "/usage": "Show cost summary", - "/version": "Show hawk version", - "/vim": "Toggle vim mode", - "/welcome": "Re-print the welcome header", - "/ecosystem": "Show eyrie, yaad, and tok integration status", - "/path": "Developer path readiness (setup, security, sandbox)", - "/yaad": "Show yaad memory (use /yaad search to search)", - "/cron": "Show scheduled jobs", - "/keybindings": "Show keyboard shortcuts", - "/output-style": "Change output style", - "/plugin": "Manage plugins", - "/pr-comments": "Address PR comments", - "/provider-status": "Show provider info", - "/release-notes": "Draft release notes", - "/reload-plugins": "Reload all plugins", - "/remote-env": "Show remote environment", - "/rename": "Rename current session", - "/render": "Export repo as CXML to clipboard", - "/research": "Start autonomous research loop", - "/session": "Show session info", - "/share": "Share session", - "/statusline": "Show status line info", - "/tag": "Tag current session", - "/taste": "Show learned taste preferences", - "/theme": "Change visual theme (opens picker)", - "/themes": "List all available themes", - "/think-back": "Review reasoning decisions", - "/thinkback": "Review reasoning decisions", - "/thinkback-play": "Replay reasoning path", - "/upgrade": "Check for updates", - "/vibe": "Start vibe coding loop", - "/voice": "Toggle voice input", - "/ctx": "Show conversation context visualization", - "/insights": "Generate session patterns and improvements report", - "/spec": "Start the spec-driven workflow (gates Write/Edit/Bash until approved)", - "/ultrareview": "Deep adversarial code review", + "/add": "Add files to conversation context", + "/add-dir": "Add a directory to context", + "/agents": "List active agents", + "/agents-init": "Generate AGENTS.md from project template", + "/audit": "Show tool audit summary", + "/autonomy": "Autonomy Center for trust tier, sandbox, and rules", + "/branch": "Show git branch info", + "/btw": "Side note without triggering a response", + "/bughunter": "Hunt for bugs in the codebase", + "/check": "Review diff, find issues, auto-fix safe ones, verify before ship", + "/design": "Build or improve UI — use /design screenshot|system|component|regress for advanced modes", + "/hunt": "Diagnose root cause of errors before fixing (Waza method)", + "/think": "Turn rough idea into approved plan before coding (Waza method)", + "/clean": "Delete old sessions", + "/clear": "Clear conversation", + "/color": "Change agent color", + "/commit": "Auto-commit changes with AI message", + "/compact": "Compress conversation to save tokens", + "/compress": "Compress old sessions", + "/config": "Open settings panel", + "/context": "Show current context", + "/copy": "Copy chat or input to clipboard (/copy all|input|last|assistant)", + "/cost": "Show token usage and cost", + "/council": "Run LLM Council (multi-model consensus)", + "/diff": "Show git diff (preview changes)", + "/doctor": "Run diagnostics (build, test, lint)", + "/drop": "Remove file from context", + "/effort": "Set reasoning effort level", + "/glm": "Toggle GLM/Z.ai extended reasoning (on|off|default)", + "/env": "Show environment info", + "/exit": "Save and exit", + "/explain": "Trace code back to the commit that created it", + "/export": "Export session", + "/follow": "Toggle stream follow (auto-scroll)", + "/home": "Jump to top of chat and welcome header", + "/feedback": "Submit feedback about hawk", + "/fast": "Toggle fast mode", + "/files": "Show modified files", + "/focus": "Narrow agent attention to specific files/dirs", + "/fork": "Fork conversation to try a different approach", + "/branches": "List or switch conversation branches", + "/help": "Show all commands", + "/history": "List saved sessions", + "/hooks": "Show configured hooks", + "/init": "Analyze project structure", + "/integrity": "Validate session integrity", + "/lint": "Run linter, add issues to context", + "/loop": "Schedule recurring command", + "/mcp": "Show MCP server status", + "/memory": "Show AGENTS.md project instructions", + "/metrics": "Show session metrics", + "/model": "Switch or view current model", + "/new": "Start a fresh session", + "/pin": "Pin last N messages to protect from compaction", + "/parallel": "Run N agents in parallel on independent tasks", + "/plugins": "List installed plugins", + "/power": "Set power level (1-10)", + "/quit": "Save and exit", + "/recover": "Scan for interrupted sessions and resume", + "/refactor": "Agent-driven refactoring: dedup, dead code, lint fixes", + "/resume": "Resume a saved session", + "/retry": "Redo last message", + "/review": "Code review for bugs and issues", + "/rewind": "Undo last exchange", + "/run": "Run command, add output to context", + "/search": "Search across sessions", + "/select": "Pause TUI for native text selection", + "/mouse": "Toggle TUI mouse capture for native click-drag copy", + "/snapshot": "Manage file snapshots: list, restore , diff ", + "/stale": "Show stale rules that may need updating or removal", + "/security-review": "Security audit", + "/skills": "List skills or manage: search, install, trending, info, remove, update, feedback, publish, audit", + "/learn": "LLM-powered skill advisor (/learn deep for source analysis)", + "/stats": "Show analytics stats", + "/status": "Show session info", + "/summary": "Summarize the session", + "/tasks": "Show task list", + "/test": "Run tests, add failures to context", + "/tokens": "Show token estimate", + "/tools": "List enabled tools", + "/undo": "Undo the most recent file change", + "/usage": "Show cost summary", + "/version": "Show hawk version", + "/vim": "Toggle vim mode", + "/welcome": "Re-print the welcome header", + "/ecosystem": "Show eyrie, yaad, and tok integration status", + "/path": "Developer path readiness (setup, security, sandbox)", + "/yaad": "Show yaad memory (use /yaad search to search)", + "/cron": "Show scheduled jobs", + "/keybindings": "Show keyboard shortcuts", + "/output-style": "Change output style", + "/plugin": "Manage plugins", + "/pr-comments": "Address PR comments", + "/provider-status": "Show provider info", + "/release-notes": "Draft release notes", + "/reload-plugins": "Reload all plugins", + "/remote-env": "Show remote environment", + "/rename": "Rename current session", + "/render": "Export repo as CXML to clipboard", + "/research": "Start autonomous research loop", + "/session": "Show session info", + "/share": "Share session", + "/statusline": "Show status line info", + "/tag": "Tag current session", + "/taste": "Show learned taste preferences", + "/theme": "Change visual theme (opens picker)", + "/themes": "List all available themes", + "/think-back": "Review reasoning decisions", + "/thinkback": "Review reasoning decisions", + "/thinkback-play": "Replay reasoning path", + "/upgrade": "Check for updates", + "/vibe": "Start vibe coding loop", + "/voice": "Toggle voice input", + "/ctx": "Show conversation context visualization", + "/insights": "Generate session patterns and improvements report", + "/spec": "Start the spec-driven workflow (gates Write/Edit/Bash until approved)", + "/ultrareview": "Deep adversarial code review", + "/scroll-speed": "Set scroll speed (1-100)", + "/scroll-invert": "Toggle scroll direction inversion", + "/scroll-mode": "Switch scroll behavior mode", + "/terminal-setup": "Configure terminal capabilities", + "/pager-config": "Configure pager for long output", + "/prompt-queue": "Manage queued prompts", + "/brainstorm": "Brainstorm ideas with multi-model council", + "/checkpoint": "Create a named checkpoint of current state", + "/dream": "Enter dream/imagining mode for creative tasks", + "/away": "Set away status with auto-reply message", + "/investigate": "Deep-dive investigation of an issue", + "/refresh-model-catalog": "Refresh the model catalog from providers", + "/image": "Generate or process images", + "/recipe": "Run a saved recipe (command template)", + "/soul": "Show or update hawk's personality/soul", + "/mode": "Switch interaction mode", + "/party": "Start a multi-agent party session", } func slashSuggestions(input string) []string { @@ -324,6 +351,12 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { parts := strings.Fields(text) cmd := parts[0] + // Track the last command for context-aware tips and recent-command history. + if strings.HasPrefix(cmd, "/") { + m.lastCommand = cmd + recordCommandUsed(cmd) + } + // Namespaced skill invocation: /vendor:skill-name [args...] if strings.Contains(cmd, ":") && strings.HasPrefix(cmd, "/") { return m.handleNamespacedSkill(cmd, text) @@ -354,10 +387,94 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { } return m, nil } - m.messages = append(m.messages, displayMsg{role: "error", content: fmt.Sprintf("Unknown command: %s (type /help)", cmd)}) + // "Did you mean?" — fuzzy-match against known slash commands so a typo + // like /commmit suggests /commit instead of just saying "unknown". + suggestion := suggestCommand(cmd) + if suggestion != "" { + m.messages = append(m.messages, displayMsg{role: "error", content: fmt.Sprintf("Unknown command: %s — did you mean %s?\nType /help for all commands.", cmd, suggestion)}) + } else { + m.messages = append(m.messages, displayMsg{role: "error", content: fmt.Sprintf("Unknown command: %s (type /help)", cmd)}) + } return m, nil } +// suggestCommand finds the closest known slash command to a mistyped one +// using edit distance (Levenshtein). Returns the best match if it is within +// a plausible typo threshold, or "" if nothing is close enough to recommend. +func suggestCommand(typo string) string { + if len(typo) < 2 { + return "" + } + clean := strings.ToLower(strings.TrimPrefix(typo, "/")) + if clean == "" { + return "" + } + best := "" + bestDist := 999 + for _, cmd := range slashCommands() { + target := strings.ToLower(strings.TrimPrefix(cmd, "/")) + d := levenshtein(clean, target) + if d < bestDist { + bestDist = d + best = cmd + } + } + if best == "" { + return "" + } + // Threshold: distance must be small relative to the command length. + // Allows 1 edit for short commands (<=5 chars), 2 for longer ones. + target := strings.ToLower(strings.TrimPrefix(best, "/")) + maxDist := 1 + if len(target) > 5 { + maxDist = 2 + } + // Never suggest when the input is longer than the target by more than + // maxDist — that's not a typo, it's a different word. + if len(clean) > len(target)+maxDist { + return "" + } + if bestDist <= maxDist && bestDist > 0 { + return best + } + return "" +} + +// levenshtein computes the edit distance between two strings using the +// classic Wagner–Fischer algorithm with O(min(m,n)) space. +func levenshtein(a, b string) int { + if a == b { + return 0 + } + if len(a) == 0 { + return len(b) + } + if len(b) == 0 { + return len(a) + } + // Ensure b is the shorter string for O(min(m,n)) space. + if len(b) > len(a) { + a, b = b, a + } + prev := make([]int, len(b)+1) + curr := make([]int, len(b)+1) + for j := 0; j <= len(b); j++ { + prev[j] = j + } + for i := 1; i <= len(a); i++ { + curr[0] = i + for j := 1; j <= len(b); j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + curr[j] = min(prev[j]+1, min(curr[j-1]+1, prev[j-1]+cost)) + } + prev, curr = curr, prev + } + return prev[len(b)] +} + // handleParallelCommand spawns multiple agents in parallel on independent tasks. // Usage: /parallel | | ... func (m *chatModel) handleParallelCommand(parts []string, text string) (tea.Model, tea.Cmd) { @@ -391,23 +508,39 @@ func (m *chatModel) handleParallelCommand(parts []string, text string) (tea.Mode grid := NewAgentGrid(taskDescs, m.width, m.height-10) m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("%s Spawning %d parallel agents for %d tasks...", icons.Bolt(), workers, len(taskDescs))}) + // Create a cancellable context for the parallel agents. + // This ensures agents are cancelled when the user quits. + // Cancel any prior parallel run first — only one runs at a time + // (concurrent runs would also clobber each other's grid display), + // and this prevents orphaning the previous run's agents on quit. + if m.parallelCancel != nil { + m.parallelCancel() + } + ctx, cancel := context.WithCancel(context.Background()) + m.parallelCancel = cancel + // Run parallel agents in background go func() { + defer cancel() // Ensure cleanup when goroutine exits + pool := parallel.NewPool(cwd, "main", workers) for _, desc := range taskDescs { pool.AddTask(desc) } + // Use atomic counter for task index to avoid race condition. + var taskIdx int32 + // Update grid as agents run - taskIdx := 0 - err := pool.Run(context.Background(), func(ctx context.Context, worktreePath string, task *parallel.Task) (string, error) { - pane := grid.GetPane(fmt.Sprintf("%d", taskIdx+1)) + err := pool.Run(ctx, func(ctx context.Context, worktreePath string, task *parallel.Task) (string, error) { + idx := int(atomic.AddInt32(&taskIdx, 1) - 1) + + pane := grid.GetPane(fmt.Sprintf("%d", idx+1)) if pane != nil { pane.SetState(AgentRunning) pane.Append(fmt.Sprintf("Starting in worktree: %s", worktreePath)) m.ref.Send(streamChunkMsg(grid.Render())) } - taskIdx++ // Clone the engine-backed transport so parallel agents cannot bypass // the parent session's resolved gateway policy. @@ -430,6 +563,11 @@ func (m *chatModel) handleParallelCommand(parts []string, text string) (tea.Mode var result strings.Builder for ev := range ch { + select { + case <-ctx.Done(): + return result.String(), ctx.Err() + default: + } switch ev.Type { case "content": result.WriteString(ev.Content) diff --git a/cmd/chat_commands_session.go b/cmd/chat_commands_session.go index 1c113d6f..a91ce305 100644 --- a/cmd/chat_commands_session.go +++ b/cmd/chat_commands_session.go @@ -44,6 +44,30 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string switch cmd { case "/quit", "/exit": m.saveSession() + // Cancel any running /loop goroutine. + if m.loopCancel != nil { + m.loopCancel() + m.loopCancel = nil + } + // Cancel any running /parallel agents. + if m.parallelCancel != nil { + m.parallelCancel() + m.parallelCancel = nil + } + // Re-enable system sleep if it was prevented. + if m.sleepCancel != nil { + m.sleepCancel() + m.sleepCancel = nil + } + // Stop file watcher if active. + if m.watcherStop != nil { + m.watcherStop() + } + // Cancel background goroutines. + if m.bgCancel != nil { + m.bgCancel() + } + ClearTabProgress() m.quitting = true return m, tea.Quit @@ -56,6 +80,11 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string m.loopCancel() m.loopCancel = nil } + // Cancel any running /parallel agents. + if m.parallelCancel != nil { + m.parallelCancel() + m.parallelCancel = nil + } m.messages = []displayMsg{{role: "system", content: "Conversation cleared."}} m.invalidateViewportCache() m.viewDirty = true @@ -75,11 +104,22 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string return m.startManualCompact() case "/diff": - stat, _ := gitOutput("diff", "--stat") - diff, _ := gitOutput("diff") + stat, statErr := gitOutput("diff", "--stat") + diff, diffErr := gitOutput("diff") if strings.TrimSpace(diff) == "" { - stat, _ = gitOutput("diff", "--cached", "--stat") - diff, _ = gitOutput("diff", "--cached") + stat, statErr = gitOutput("diff", "--cached", "--stat") + diff, diffErr = gitOutput("diff", "--cached") + } + // Report git errors instead of silently showing "No changes detected". + if statErr != nil || diffErr != nil { + errMsg := "git diff failed" + if statErr != nil { + errMsg = statErr.Error() + } else if diffErr != nil { + errMsg = diffErr.Error() + } + m.messages = append(m.messages, displayMsg{role: "error", content: errMsg}) + return m, nil } if strings.TrimSpace(diff) == "" { m.messages = append(m.messages, displayMsg{role: "system", content: "No changes detected."}) @@ -186,7 +226,15 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) return m, nil } - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Forked at %s → new branch %s\nYou can now take a different approach. Use /branches to see all branches.", headID[:8], forkID[:8])}) + shortHead := headID + if len(shortHead) > 8 { + shortHead = shortHead[:8] + } + shortFork := forkID + if len(shortFork) > 8 { + shortFork = shortFork[:8] + } + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Forked at %s → new branch %s\nYou can now take a different approach. Use /branches to see all branches.", shortHead, shortFork)}) return m, nil } // Fallback: legacy session fork @@ -209,7 +257,28 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string return m, nil case "/export": - exportPath, err := writeRedactedChatMarkdownExport(m) + format := "md" + if len(parts) >= 2 { + switch strings.ToLower(parts[1]) { + case "md", "markdown": + format = "md" + case "json": + format = "json" + case "txt", "text": + format = "txt" + default: + m.messages = append(m.messages, displayMsg{ + role: "system", + content: "Usage: /export [format]\n" + + " /export — export as Markdown (default)\n" + + " /export md — export as Markdown\n" + + " /export json — export as structured JSON\n" + + " /export txt — export as plain text", + }) + return m, nil + } + } + exportPath, err := exportSession(m, format) if err != nil { m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) } else { @@ -231,9 +300,14 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string m.messages = append(m.messages, displayMsg{role: "system", content: "Usage: /rename "}) return m, nil } - newName := parts[1] + // Sanitize: strip directory components to prevent path traversal. + newName := filepath.Base(parts[1]) + if newName == "" || newName == "." || newName == "/" { + m.messages = append(m.messages, displayMsg{role: "error", content: "Invalid session name."}) + return m, nil + } sessDir := storage.SessionsDir() - oldPath := filepath.Join(sessDir, m.sessionID+".jsonl") + oldPath := filepath.Join(sessDir, filepath.Base(m.sessionID)+".jsonl") newPath := filepath.Join(sessDir, newName+".jsonl") if err := os.Rename(oldPath, newPath); err != nil { m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) @@ -248,7 +322,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string m.messages = append(m.messages, displayMsg{role: "system", content: "Usage: /tag