Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ with its native Responses API (`/v1/responses`) under the
<!-- gitnexus:start -->
## GitNexus — Code Intelligence

This project is indexed by GitNexus as **hawk** (88034 symbols, 273602 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **hawk** (86470 symbols, 279855 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.

> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).

Expand Down
7 changes: 5 additions & 2 deletions cmd/autoinit.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
Expand Down Expand Up @@ -37,10 +38,12 @@ func maybeAutoInit(ctx context.Context) {
}
go func() {
// MaybeRun handles all gating (disable env, marker, existing context).
_, _ = autoinit.MaybeRun(ctx, autoinit.Options{
if _, err := autoinit.MaybeRun(ctx, autoinit.Options{
Root: root,
Run: autoInitRunner,
})
}); err != nil {
slog.Warn("auto-init failed", "root", root, "error", err)
}
}()
}

Expand Down
4 changes: 4 additions & 0 deletions cmd/autonomy_tiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ var containerAutonomyTierNames = []string{
// DefaultContainerAutonomy is the tier applied when the Docker container becomes ready.
const DefaultContainerAutonomy = engine.AutonomySemi

// yoloConfirmToken is the exact string a user must type (case-insensitive) to
// confirm entry into YOLO ("Autonomous") unattended mode via the picker.
const yoloConfirmToken = "continue"

func autonomyTierName(level engine.AutonomyLevel) string {
if level == engine.AutonomySupervised {
return "Always Ask"
Expand Down
64 changes: 46 additions & 18 deletions cmd/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"log"
"log/slog"
"math/rand"
"os"
"os/signal"
Expand Down Expand Up @@ -103,14 +104,6 @@ func prepareSession(sess *engine.Session) (string, *session.Session, error) {
return saved.ID, saved, nil
}

func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Settings) (chatModel, error) {
registry, err := defaultRegistry(settings)
if err != nil {
return chatModel{}, err
}
return newChatModelWithRegistry(ref, systemPrompt, settings, registry)
}

func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkconfig.Settings, registry *tool.Registry) (chatModel, error) {
startup.MarkPhase("newChatModel:total")

Expand Down Expand Up @@ -274,10 +267,12 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco
}
startup.EndPhase("newChatModel:taste-staleness")

// Initialize write-ahead log for crash recovery
// Initialize write-ahead log for crash recovery. BatchedWAL batches
// appends + fsync on a timer so per-message writes don't stall the UI
// thread (the plain WAL syncs on every append).
startup.MarkPhase("newChatModel:wal")
if wal, err := session.NewWAL(sid); err == nil {
m.wal = wal
m.wal = session.NewBatchedWAL(wal)
_ = wal.AppendMeta(effectiveModel, effectiveProvider, "")
}
startup.EndPhase("newChatModel:wal")
Expand Down Expand Up @@ -360,8 +355,14 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco
if r.Approved && req.ContainerID != "" {
// Flip the symlink inside the container to grant access.
if desc := sandbox.FindCredential(req.Credential); desc != nil {
_ = tool.FlipCredentialSymlink(req.ContainerID, req.Credential,
sandbox.StagingPath(req.Credential), desc.ContainerPath)
if flipErr := tool.FlipCredentialSymlink(req.ContainerID, req.Credential,
sandbox.StagingPath(req.Credential), desc.ContainerPath); flipErr != nil {
// The user approved, so a flip failure must be visible:
// report it and revoke approval rather than silently
// leaving the container without the credential.
ref.Send(displayMsg{role: "system", content: fmt.Sprintf("! Credential %q approved but could not be granted to the container: %v", req.Credential, flipErr)})
return tool.CredentialResponse{Approved: false, Reason: "credential grant failed: " + flipErr.Error()}
}
}
}
return r
Expand Down Expand Up @@ -408,6 +409,11 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco
go func(model chatModel) {
startup.MarkPhase("newChatModel:ui-cache-warm")
hawkconfig.RefreshConfigCredSnapshot(context.Background())
// Network reachability runs off the startup critical path: an offline
// machine stalls here (background) instead of before first paint.
if msg := checkNetworkReachability(model.settings); msg != "" {
model.ref.Send(displayMsg{role: "warning", content: "Startup check:\n ! " + msg})
}
welcomeSnapshot := loadWelcomeStatusSnapshot()
_, _ = model.refreshStatusBarLeft(true)
connStatusVal := ""
Expand Down Expand Up @@ -524,14 +530,14 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco
// refreshInputPlaceholder updates the input placeholder based on the current
// container lifecycle. Hawk never executes agent tools directly on the host.
func (m *chatModel) refreshInputPlaceholder() {
work := "act"
work := engine.WorkModeAct
if m.session != nil {
work = string(m.session.WorkMode())
work = m.session.WorkMode()
}
switch work {
case "plan":
case engine.WorkModePlan:
m.input.Placeholder = "Design architecture or draft plan... · / commands · ? help"
case "review":
case engine.WorkModeReview:
m.input.Placeholder = "Audit diffs, security, or PRs... · / commands · ? help"
default:
m.input.Placeholder = "Build, refactor, or run commands... · / commands · ? help"
Expand Down Expand Up @@ -594,14 +600,30 @@ func autoIndexCodegraph() {

// Incremental sync — only processes changed files
if _, err := cg.Sync(); err != nil {
log.Printf("codegraph sync: %v", err)
slog.Warn("codegraph sync failed", "error", err)
}
}

func runChat() error {
startup.Reset()
startBackgroundCatalogRefresh(context.Background())

// On an unexpected panic, persist the active session and stop the
// container so a crash loses at most the in-flight message and never
// leaves a zombie Docker sandbox. The closure captures the model once it
// exists; before that, saveFn is a no-op (nothing to save).
var active *chatModel
panicSaveFn = func() {
if active == nil {
return
}
if active.session != nil && active.sessionID != "" {
active.saveSession()
}
active.stopContainer()
}
defer func() { panicSaveFn = nil }()

// Auto-index codegraph in background if .codegraph exists
go autoIndexCodegraph()

Expand Down Expand Up @@ -662,10 +684,13 @@ func runChat() error {
}
systemPrompt := promptRes.text
settings := settingsRes.settings
m, err := newChatModel(ref, systemPrompt, settings)
// Pass the registry already built by the runChat goroutine — rebuilding it
// here would re-run MCP server startup (up to 1.5s each) a second time.
m, err := newChatModelWithRegistry(ref, systemPrompt, settings, registryRes.registry)
if err != nil {
return err
}
active = &m

if promptFlag != "" {
if e := (&m).ensureSessionReadyForChat(); e != nil {
Expand All @@ -692,11 +717,14 @@ func runChat() error {
// Forward SIGHUP (terminal close, ssh drop, window manager exit) into the
// TUI as a tea.QuitMsg so the session is saved and cleaned up instead of
// dying silently mid-run. Bubble Tea only handles SIGINT and SIGTERM.
// The forwarder deregisters itself after the first SIGHUP so repeated
// runChat() invocations do not leak signal handlers or goroutines.
{
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGHUP)
go func() {
<-sigCh
signal.Stop(sigCh)
ref.Send(tea.QuitMsg{})
}()
}
Expand Down
58 changes: 34 additions & 24 deletions cmd/chat_commands_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,35 @@ func (m *chatModel) saveSession() {
}
}

// saveSessionCmd returns a background tea.Cmd that persists the session. It
// captures the messages and metadata up front so the write happens off the UI
// thread (large sessions would otherwise hitch the completion frame).
// Atomic tmp+rename in session.Save makes backgrounding safe. The WAL is
// removed only after a successful save, preserving the durability ordering.
func (m *chatModel) saveSessionCmd() tea.Cmd {
if m == nil || m.session == nil || m.sessionID == "" {
return nil
}
raw := m.session.RawMessages()
if len(raw) == 0 {
return nil
}
id, modelName, provider := m.sessionID, m.session.Model(), m.session.Provider()
msgs := session.FromRuntimeMessages(raw)
createdAt := time.Now()
wal := m.wal
return func() tea.Msg {
err := session.Save(&session.Session{
ID: id, Model: modelName, Provider: provider,
Messages: msgs, CreatedAt: createdAt,
})
if err == nil && wal != nil {
_ = wal.Remove()
}
return nil
}
}

func formatQuitResumeMessage(sessionID string) string {
if strings.TrimSpace(sessionID) == "" {
return "Thank you for using Hawk!\n"
Expand All @@ -43,34 +72,15 @@ func formatQuitResumeMessage(sessionID string) string {
func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string) (tea.Model, tea.Cmd) {
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.
// Re-enable system sleep if it was prevented, then use the canonical
// quit sequence (cancel stream → save → stop watcher/parallel/bg →
// stop container) rather than a hand-rolled duplicate that previously
// missed cancelling the in-flight stream.
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()
}
m.stopContainer()
ClearTabProgress()
m.quitting = true
return m, tea.Quit
return m.quitModel()

case "/clear":
if m.manualCompacting {
Expand Down
12 changes: 9 additions & 3 deletions cmd/chat_config_gateways_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ func TestConfigGatewayRefreshTargetIndex_UsesSelectedRow(t *testing.T) {
gateway.SetDefaultStore(nil)
hawkconfig.InvalidateConfigUICache()
})
_ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890")
if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil {
t.Fatalf("store.Set: %v", err)
}
hawkconfig.InvalidateConfigUICache()

sess := engine.NewSession("", "", "", nil)
Expand All @@ -143,7 +145,9 @@ func TestConfigGatewayRefreshTargetIndex_UsesFocusOnRefreshRow(t *testing.T) {
gateway.SetDefaultStore(nil)
hawkconfig.InvalidateConfigUICache()
})
_ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890")
if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil {
t.Fatalf("store.Set: %v", err)
}
hawkconfig.InvalidateConfigUICache()

rows := []configGatewayRow{
Expand All @@ -165,7 +169,9 @@ func TestFocusConfigActiveGateway_SelectsActiveRow(t *testing.T) {
gateway.SetDefaultStore(nil)
hawkconfig.InvalidateConfigUICache()
})
_ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890")
if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil {
t.Fatalf("store.Set: %v", err)
}
hawkconfig.InvalidateConfigUICache()

sess := engine.NewSession("", "", "", nil)
Expand Down
16 changes: 12 additions & 4 deletions cmd/chat_config_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ func TestConfigGatewaysView_KeyHintsWithCredentials(t *testing.T) {
gateway.SetDefaultStore(nil)
hawkconfig.InvalidateConfigUICache()
})
_ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890")
if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil {
t.Fatalf("store.Set: %v", err)
}
hawkconfig.InvalidateConfigUICache()

m := chatModel{configTab: configTabGateways}
Expand All @@ -35,7 +37,9 @@ func TestConfigGatewaysKeyView_OpenWithK(t *testing.T) {
gateway.SetDefaultStore(nil)
hawkconfig.InvalidateConfigUICache()
})
_ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890")
if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil {
t.Fatalf("store.Set: %v", err)
}
hawkconfig.InvalidateConfigUICache()

rows := chatModel{}.configGatewayRows()
Expand Down Expand Up @@ -64,7 +68,9 @@ func TestConfigGatewaysDelete_PendingRemove(t *testing.T) {
gateway.SetDefaultStore(nil)
hawkconfig.InvalidateConfigUICache()
})
_ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890")
if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil {
t.Fatalf("store.Set: %v", err)
}
hawkconfig.InvalidateConfigUICache()

rows := chatModel{}.configGatewayRows()
Expand Down Expand Up @@ -93,7 +99,9 @@ func TestConfigGatewaysDelete_DoubleConfirm(t *testing.T) {
gateway.SetDefaultStore(nil)
hawkconfig.InvalidateConfigUICache()
})
_ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890")
if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil {
t.Fatalf("store.Set: %v", err)
}
hawkconfig.InvalidateConfigUICache()

rows := chatModel{}.configGatewayRows()
Expand Down
12 changes: 9 additions & 3 deletions cmd/chat_config_remove_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ func TestConfigGatewayRows_ShowsSavedKey(t *testing.T) {
gateway.SetDefaultStore(nil)
hawkconfig.InvalidateConfigUICache()
})
_ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890")
if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil {
t.Fatalf("store.Set: %v", err)
}
hawkconfig.InvalidateConfigUICache()

rows := chatModel{}.configGatewayRows()
Expand All @@ -40,7 +42,9 @@ func TestConfiguredCredentialProviders_UsedByGatewaysTab(t *testing.T) {
gateway.SetDefaultStore(nil)
hawkconfig.InvalidateConfigUICache()
})
_ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890")
if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil {
t.Fatalf("store.Set: %v", err)
}
hawkconfig.InvalidateConfigUICache()

got := hawkconfig.ConfiguredCredentialProviders()
Expand All @@ -57,7 +61,9 @@ func TestRemoveCredentialAsync(t *testing.T) {
gateway.SetDefaultStore(nil)
hawkconfig.InvalidateConfigUICache()
})
_ = store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890")
if err := store.Set(t.Context(), gateway.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil {
t.Fatalf("store.Set: %v", err)
}
hawkconfig.InvalidateConfigUICache()

cmd := removeCredentialAsync("openrouter")
Expand Down
Loading
Loading