From 2deeacf2b14b9a9cdc31aee0e22dcb95a7f71ad5 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 17 Jun 2026 19:31:47 +0530 Subject: [PATCH 01/14] fix(cmd): M1 part 1 - pinSubcommand, GLM thinking, status use accessors - chat_subcommand_pin.go: use m.session.SetPinnedMessages - chat_subcommand_simple.go: use m.session.SetGLMThinkingEnabled - chat_subcommand_status.go: use m.session.CostValue().Summary() - session.go: add SetPinnedMessages accessor --- cmd/chat_subcommand_pin.go | 2 +- cmd/chat_subcommand_simple.go | 6 +++--- cmd/chat_subcommand_status.go | 2 +- internal/engine/session.go | 10 ++++++++++ 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/cmd/chat_subcommand_pin.go b/cmd/chat_subcommand_pin.go index da6599c5..f8bb6e3e 100644 --- a/cmd/chat_subcommand_pin.go +++ b/cmd/chat_subcommand_pin.go @@ -25,7 +25,7 @@ func (p *pinSubcommand) Handle(m *chatModel, args []string, text string) (tea.Mo n = parsed } } - m.session.PinnedMessages = n + m.session.SetPinnedMessages(n) m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Pinned last %d messages (protected from compaction).", n)}) return m, nil } diff --git a/cmd/chat_subcommand_simple.go b/cmd/chat_subcommand_simple.go index 45a027a8..965494aa 100644 --- a/cmd/chat_subcommand_simple.go +++ b/cmd/chat_subcommand_simple.go @@ -296,16 +296,16 @@ func init() { case "on": _ = hawkconfig.SetGlobalSetting("glmthinking", "true") enabled := true - m.session.GLMThinkingEnabled = &enabled + m.session.SetGLMThinkingEnabled(&enabled) m.messages = append(m.messages, displayMsg{role: "system", content: "GLM thinking → enabled"}) case "off": _ = hawkconfig.SetGlobalSetting("glmthinking", "false") disabled := false - m.session.GLMThinkingEnabled = &disabled + m.session.SetGLMThinkingEnabled(&disabled) m.messages = append(m.messages, displayMsg{role: "system", content: "GLM thinking → disabled"}) case "default": _ = hawkconfig.SetGlobalSetting("glmthinking", "default") - m.session.GLMThinkingEnabled = nil + m.session.SetGLMThinkingEnabled(nil) m.messages = append(m.messages, displayMsg{role: "system", content: "GLM thinking → default (model decides)"}) default: m.messages = append(m.messages, displayMsg{role: "error", content: "Valid options: on, off, default"}) diff --git a/cmd/chat_subcommand_status.go b/cmd/chat_subcommand_status.go index 4656c8dd..509fde50 100644 --- a/cmd/chat_subcommand_status.go +++ b/cmd/chat_subcommand_status.go @@ -32,7 +32,7 @@ func buildStatusInfo(m *chatModel) string { info := fmt.Sprintf("Session: %s\nModel: %s/%s\nMode: %s\nPermission mode: %s\nMessages: %d\nTools: %d\n%s", m.sessionID, m.session.Provider(), m.session.Model(), m.modeManager.Current().String(), - permissionModeLabel(m.session), m.session.MessageCount(), toolCount, m.session.Cost.Summary()) + permissionModeLabel(m.session), m.session.MessageCount(), toolCount, m.session.CostValue().Summary()) if len(addDirs) > 0 { info += "\nAdditional dirs: " + strings.Join(addDirs, ", ") } diff --git a/internal/engine/session.go b/internal/engine/session.go index a5311f3d..68216850 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -677,6 +677,16 @@ func (s *Session) SetAutoCompactThresholdPct(pct int) { s.AutoCompactThresholdPct = pct } +// SetPinnedMessages sets the number of recent messages that are +// protected from compaction. New code should call this instead of +// writing to the legacy s.PinnedMessages field directly. +func (s *Session) SetPinnedMessages(n int) { + s.PinnedMessages = n + if s.persist != nil { + s.persist.SetPinnedMessages(n) + } +} + // SetGLMThinkingEnabled sets the GLM/Z.AI extended-reasoning toggle. // New code should call this instead of writing to the legacy // s.GLMThinkingEnabled field directly. From 1abfb17e08c88bbe170f3c04eaccacbeee67894e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 17 Jun 2026 19:35:23 +0530 Subject: [PATCH 02/14] fix(cmd): M1 part 2 - Autonomy, ContainerExecutor, ConvoDAG accessors - chat.go: use PermSvc().Autonomy()/SetAutonomy, SetContainerExecutor, SetContainerRequired - permissions_center.go: use PermSvc().SetAutonomy - welcome_gate.go, statusbar.go: use PermSvc().Autonomy() - hud_panel.go: use MemorySvc().Yaad() - chat_status.go: use ContextWindowCachedValue, Persistence().SetContextWindowCached - chat_subcommand_branches.go: use Persistence().DAG() - chat_commands_session.go: use Persistence().DAG() - session.go: add SetContainerExecutor accessor - audit_test.go: regex now matches m.session.X; cmdHardFailThreshold=0 --- cmd/chat.go | 18 +++++++++--------- cmd/chat_commands_session.go | 2 +- cmd/chat_status.go | 4 ++-- cmd/chat_subcommand_branches.go | 2 +- cmd/hud_panel.go | 2 +- cmd/permissions_center.go | 4 ++-- cmd/statusbar.go | 4 ++-- cmd/welcome_gate.go | 2 +- internal/engine/session.go | 10 ++++++++++ internal/testaudit/audit_test.go | 23 ++++++++++++----------- 10 files changed, 41 insertions(+), 30 deletions(-) diff --git a/cmd/chat.go b/cmd/chat.go index 058d5f94..52f3f27e 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -896,11 +896,11 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateViewportContent() return m, nil } - next := nextAutonomyTier(m.session.Autonomy) - if m.session.Autonomy == 0 || autonomyTierIndex(m.session.Autonomy) < 0 { + next := nextAutonomyTier(m.session.PermSvc().Autonomy()) + if m.session.PermSvc().Autonomy() == 0 || autonomyTierIndex(m.session.PermSvc().Autonomy()) < 0 { next = DefaultContainerAutonomy } - m.session.Autonomy = next + m.session.PermSvc().SetAutonomy(next) m.invalidateConnStatus() m.messages = append(m.messages, displayMsg{role: "system", content: formatAutonomyTierMessage(next)}) m.viewDirty = true @@ -1275,17 +1275,17 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.sandbox != nil { m.containerSandbox = msg.sandbox if m.session != nil { - m.session.ContainerExecutor = msg.sandbox + m.session.SetContainerExecutor(msg.sandbox) } } if msg.ready && m.session != nil { - if m.session.Autonomy == 0 { - m.session.Autonomy = DefaultContainerAutonomy + if m.session.PermSvc().Autonomy() == 0 { + m.session.PermSvc().SetAutonomy(DefaultContainerAutonomy) } if m.phase == phaseWelcomeGate { m.sandboxReadyPending = true } else { - m.messages = append(m.messages, displayMsg{role: "system", content: formatSandboxReadyAutonomyMessage(m.session.Autonomy)}) + m.messages = append(m.messages, displayMsg{role: "system", content: formatSandboxReadyAutonomyMessage(m.session.PermSvc().Autonomy())}) } m.invalidateConnStatus() } @@ -1294,8 +1294,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.containerEnabled = false m.containerReady = false if m.session != nil { - m.session.ContainerRequired = false - m.session.ContainerExecutor = nil + m.session.SetContainerRequired(false) + m.session.SetContainerExecutor(nil) } m.messages = append(m.messages, displayMsg{ role: "system", diff --git a/cmd/chat_commands_session.go b/cmd/chat_commands_session.go index 17824389..d0086fbb 100644 --- a/cmd/chat_commands_session.go +++ b/cmd/chat_commands_session.go @@ -200,7 +200,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string case "/fork": // If convodag is active, fork from the current head node - if m.session.ConvoDAG != nil { + if m.session.Persistence().DAG() != nil { headID := m.session.ConvoHead() if headID == "" { m.messages = append(m.messages, displayMsg{role: "error", content: "No conversation to fork from."}) diff --git a/cmd/chat_status.go b/cmd/chat_status.go index 8a312eeb..19ccde7f 100644 --- a/cmd/chat_status.go +++ b/cmd/chat_status.go @@ -139,7 +139,7 @@ func (m chatModel) connectionStatusParts() (gateway, model, contextLabel string) model, contextLabel = modelStatusMeta(gw, modelID) if contextLabel == "" || contextLabel == "—" || contextLabel == "0k" { if m.session != nil { - if w := m.session.ContextWindowCached; w > 0 { + if w := m.session.ContextWindowCachedValue(); w > 0 { contextLabel = formatModelTableContext(w) } else if w := m.session.ContextWindowSize(); w > 0 && w != engine.DefaultContextWindow { contextLabel = formatModelTableContext(w) @@ -149,7 +149,7 @@ func (m chatModel) connectionStatusParts() (gateway, model, contextLabel string) if w := platformContextForNativeModel(modelID); w > 0 { contextLabel = formatModelTableContext(w) if m.session != nil { - m.session.ContextWindowCached = w + m.session.Persistence().SetContextWindowCached(w) m.session.EnsureAutoCompactor() } } diff --git a/cmd/chat_subcommand_branches.go b/cmd/chat_subcommand_branches.go index abb6ea50..fae36dbc 100644 --- a/cmd/chat_subcommand_branches.go +++ b/cmd/chat_subcommand_branches.go @@ -14,7 +14,7 @@ func (b *branchesSubcommand) Aliases() []string { return nil } func (b *branchesSubcommand) Description() string { return "list conversation DAG branches" } func (b *branchesSubcommand) Usage() string { return "" } func (b *branchesSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { - if m.session.ConvoDAG == nil { + if m.session.Persistence().DAG() == nil { m.messages = append(m.messages, displayMsg{role: "system", content: "No conversation branches (DAG not active)."}) return m, nil } diff --git a/cmd/hud_panel.go b/cmd/hud_panel.go index 125dd306..ca42de1b 100644 --- a/cmd/hud_panel.go +++ b/cmd/hud_panel.go @@ -163,7 +163,7 @@ func (m *chatModel) collectHUDData() HUDData { data := HUDData{ MissionStatus: "idle", } - if m.session != nil && m.session.YaadBridge != nil && m.session.YaadBridge.Ready() { + if m.session != nil && m.session.MemorySvc().Yaad() != nil && m.session.MemorySvc().Yaad().Ready() { data.MemoryReady = true } return data diff --git a/cmd/permissions_center.go b/cmd/permissions_center.go index c9033b38..82c6403f 100644 --- a/cmd/permissions_center.go +++ b/cmd/permissions_center.go @@ -298,7 +298,7 @@ func resetPermissionCenter(m *chatModel) { if m == nil || m.session == nil { return } - m.session.Autonomy = DefaultContainerAutonomy + m.session.PermSvc().SetAutonomy(DefaultContainerAutonomy) m.settings.Autonomy = permissionTierSettingValue(DefaultContainerAutonomy) m.settings.Sandbox = defaultPermissionSandbox sandboxFlag = defaultPermissionSandbox @@ -347,7 +347,7 @@ func (m *chatModel) handlePermissionsCommand(parts []string) (chatModel, tea.Cmd m.messages = append(m.messages, displayMsg{role: "error", content: "Valid tiers: scout, builder, operator, autonomous"}) return *m, nil } - m.session.Autonomy = level + m.session.PermSvc().SetAutonomy(level) m.settings.Autonomy = permissionTierSettingValue(level) m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Permission tier → %s\nBehavior: %s", label, permissionBehaviorSummary(level))}) case "sandbox": diff --git a/cmd/statusbar.go b/cmd/statusbar.go index 7f380ccd..5dff3533 100644 --- a/cmd/statusbar.go +++ b/cmd/statusbar.go @@ -191,8 +191,8 @@ func containerFooterLeft(m chatModel) (bold, dim string) { } if m.containerReady && strings.TrimSpace(m.containerStatus) != "" { tier := "Builder" - if m.session != nil && m.session.Autonomy != 0 { - tier = autonomyTierName(m.session.Autonomy) + if m.session != nil && m.session.PermSvc().Autonomy() != 0 { + tier = autonomyTierName(m.session.PermSvc().Autonomy()) } status := shortenFooterContainerStatus(strings.TrimSpace(m.containerStatus)) return bold, fmt.Sprintf(" %s · %s", status, tier) diff --git a/cmd/welcome_gate.go b/cmd/welcome_gate.go index 6df01fcc..bff112bf 100644 --- a/cmd/welcome_gate.go +++ b/cmd/welcome_gate.go @@ -192,7 +192,7 @@ func (m chatModel) flushSandboxReadyMessage() chatModel { m.sandboxReadyPending = false m.messages = append(m.messages, displayMsg{ role: "system", - content: formatSandboxReadyAutonomyMessage(m.session.Autonomy), + content: formatSandboxReadyAutonomyMessage(m.session.PermSvc().Autonomy()), }) m.viewDirty = true return m diff --git a/internal/engine/session.go b/internal/engine/session.go index 68216850..be0770a3 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -707,6 +707,16 @@ func (s *Session) SetContainerRequired(v bool) { s.ContainerRequired = v } +// SetContainerExecutor sets the container executor and updates +// the ToolService so the legacy s.ContainerExecutor field and +// s.Tools().ContainerExecutor() stay in sync. +func (s *Session) SetContainerExecutor(ce tool.ContainerExecutor) { + s.ContainerExecutor = ce + if s.tools != nil { + s.tools.WithContainerExecutor(ce, s.ContainerRequired) + } +} + // SetAskUserFn sets the user-prompt callback. New code should // call this instead of writing to the legacy s.AskUserFn field. func (s *Session) SetAskUserFn(fn func(question string) (string, error)) { diff --git a/internal/testaudit/audit_test.go b/internal/testaudit/audit_test.go index 39536145..a3a0ea9f 100644 --- a/internal/testaudit/audit_test.go +++ b/internal/testaudit/audit_test.go @@ -290,11 +290,12 @@ func TestSessionLegacyFieldAccessAudit(t *testing.T) { for i, f := range legacySessionFields { quoted[i] = regexp.QuoteMeta(f) } - // Match `s.Field` or `sess.Field` as a bare token. We then - // post-filter to exclude method calls (Field followed by `(`), - // which are not legacy access — they're the proper way to - // interact with the field via its getter/setter methods. - fieldPattern := regexp.MustCompile(`\bs(?:ess)?\.\s*(?:` + strings.Join(quoted, "|") + `)\b`) + // Match `s.Field`, `sess.Field`, or `m.session.Field` as a bare + // token. We then post-filter to exclude method calls (Field + // followed by `(`), which are not legacy access — they're the + // proper way to interact with the field via its getter/setter + // methods. + fieldPattern := regexp.MustCompile(`\b(?:s|sess|m\.session)\.\s*(?:` + strings.Join(quoted, "|") + `)\b`) total := 0 perFile := map[string]int{} @@ -352,12 +353,12 @@ func TestSessionLegacyFieldAccessAudit(t *testing.T) { // internal/engine/stream.go with ~120 sites), so internal/ // remains soft-fail until those sub-PRs land. // - // Current cmd/ backlog (2026-06-17): - // chat_model_test.go 1, session_sync.go 1, snapshot_cmd.go 1, - // chat_commands_util.go 1 - // All 4 are test files or false positives (method calls - // matching the field pattern). The cmd/ sub-PR is complete. - const cmdHardFailThreshold = 4 + // M1 (2026-06-17): audit now matches `m.session.X` (was `s.X` / + // `sess.X` only — a blind spot that missed all chatModel access). + // Threshold lowered to 0; remaining false-positives are + // method calls (Field followed by `(`) that the post-filter + // already strips out. + const cmdHardFailThreshold = 0 var cmdLegacy int for f, n := range perFile { if strings.HasPrefix(f, "cmd/") { From 4e4867eb372856694ca1fa653ba3246598ed28c1 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 17 Jun 2026 19:51:31 +0530 Subject: [PATCH 03/14] fix(cmd): M1 part 3 - remaining cmd/ Cost/Mode/ContextWindow accessors - statusbar.go: use m.session.CostValue() - chat_subcommand_cost.go, chat_subcommand_usage.go: use CostValue() - chat_commands_session.go, chat_commands_util.go: use CostValue() - chat.go: use ContextWindowCachedValue() - permissions_center.go: use ModeValue() (safe fallback for test struct literal) - chat_model_test.go, statusbar_test.go: use accessors - session_sync.go, snapshot_cmd.go: avoid audit regex false positives in text - session.go: add SetContextWindowCached, ModeValue, SetMode accessors --- cmd/chat.go | 2 +- cmd/chat_commands_session.go | 2 +- cmd/chat_commands_util.go | 2 +- cmd/chat_model_test.go | 2 +- cmd/chat_status.go | 2 +- cmd/chat_subcommand_cost.go | 2 +- cmd/chat_subcommand_usage.go | 2 +- cmd/permissions_center.go | 4 ++-- cmd/session_sync.go | 2 +- cmd/snapshot_cmd.go | 2 +- cmd/statusbar.go | 10 +++++----- cmd/statusbar_test.go | 4 ++-- internal/engine/session.go | 30 ++++++++++++++++++++++++++++++ 13 files changed, 48 insertions(+), 18 deletions(-) diff --git a/cmd/chat.go b/cmd/chat.go index 52f3f27e..3d996b31 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -315,7 +315,7 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting startup.EndPhase("newChatModel:commandPalette") // Pre-warm footer connection line so ctx (e.g. 0k/1.0m) shows on first paint. - if m.session != nil && m.session.ContextWindowCached > 0 { + if m.session != nil && m.session.ContextWindowCachedValue() > 0 { m.connStatusVal = m.buildConnectionStatusPlain() m.connStatusKey = m.connStatusFingerprint() } diff --git a/cmd/chat_commands_session.go b/cmd/chat_commands_session.go index d0086fbb..e1330f41 100644 --- a/cmd/chat_commands_session.go +++ b/cmd/chat_commands_session.go @@ -415,7 +415,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string case "/session": info := fmt.Sprintf("Session: %s\nModel: %s/%s\nPermission mode: %s\nMessages: %d\nTools: %d\n%s", m.sessionID, m.session.Provider(), m.session.Model(), - permissionModeLabel(m.session), m.session.MessageCount(), len(m.registry.EyrieTools()), m.session.Cost.Summary()) + permissionModeLabel(m.session), m.session.MessageCount(), len(m.registry.EyrieTools()), m.session.CostValue().Summary()) m.messages = append(m.messages, displayMsg{role: "system", content: info}) return m, nil diff --git a/cmd/chat_commands_util.go b/cmd/chat_commands_util.go index 371e4640..cbb8133d 100644 --- a/cmd/chat_commands_util.go +++ b/cmd/chat_commands_util.go @@ -123,7 +123,7 @@ func (m *chatModel) mcpSummary() string { func sessionStats(sess *engine.Session, id string) string { return fmt.Sprintf("Session: %s\nMessages: %d\nModel: %s/%s\n%s", - id, sess.MessageCount(), sess.Provider(), sess.Model(), sess.Cost.Summary()) + id, sess.MessageCount(), sess.Provider(), sess.Model(), sess.CostValue().Summary()) } func hooksSummary() string { diff --git a/cmd/chat_model_test.go b/cmd/chat_model_test.go index 62a781b0..097f802a 100644 --- a/cmd/chat_model_test.go +++ b/cmd/chat_model_test.go @@ -12,7 +12,7 @@ import ( func newTestChatModel() *chatModel { sess := engine.NewSession("", "test-model", "you are helpful", nil) - sess.MaxTurns = 1 + sess.PermSvc().SetMaxTurns(1) sess.SetTestClient(engine.NewMockClientForTest()) m := &chatModel{ diff --git a/cmd/chat_status.go b/cmd/chat_status.go index 19ccde7f..7d647c57 100644 --- a/cmd/chat_status.go +++ b/cmd/chat_status.go @@ -149,7 +149,7 @@ func (m chatModel) connectionStatusParts() (gateway, model, contextLabel string) if w := platformContextForNativeModel(modelID); w > 0 { contextLabel = formatModelTableContext(w) if m.session != nil { - m.session.Persistence().SetContextWindowCached(w) + m.session.SetContextWindowCached(w) m.session.EnsureAutoCompactor() } } diff --git a/cmd/chat_subcommand_cost.go b/cmd/chat_subcommand_cost.go index b45739f9..4d5b53fb 100644 --- a/cmd/chat_subcommand_cost.go +++ b/cmd/chat_subcommand_cost.go @@ -13,7 +13,7 @@ func (c *costSubcommand) Aliases() []string { return nil } func (c *costSubcommand) Description() string { return "print session cost and token usage summary" } func (c *costSubcommand) Usage() string { return "" } func (c *costSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { - m.messages = append(m.messages, displayMsg{role: "system", content: m.session.Cost.Summary()}) + m.messages = append(m.messages, displayMsg{role: "system", content: m.session.CostValue().Summary()}) return m, nil } diff --git a/cmd/chat_subcommand_usage.go b/cmd/chat_subcommand_usage.go index 0d82d39e..a50b93e8 100644 --- a/cmd/chat_subcommand_usage.go +++ b/cmd/chat_subcommand_usage.go @@ -13,7 +13,7 @@ func (u *usageSubcommand) Aliases() []string { return nil } func (u *usageSubcommand) Description() string { return "show session token usage (alias for /cost)" } func (u *usageSubcommand) Usage() string { return "" } func (u *usageSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { - m.messages = append(m.messages, displayMsg{role: "system", content: m.session.Cost.Summary()}) + m.messages = append(m.messages, displayMsg{role: "system", content: m.session.CostValue().Summary()}) return m, nil } diff --git a/cmd/permissions_center.go b/cmd/permissions_center.go index 82c6403f..64092236 100644 --- a/cmd/permissions_center.go +++ b/cmd/permissions_center.go @@ -157,7 +157,7 @@ func permissionCenterSummary(m *chatModel) string { b.WriteString(fmt.Sprintf(" Mode: %s\n", permissionModeLabel(m.session))) b.WriteString(fmt.Sprintf(" Rules: %d allow, %d deny\n", len(allowRules), len(denyRules))) b.WriteString(fmt.Sprintf(" Behavior: %s\n", permissionBehaviorSummary(level))) - b.WriteString(fmt.Sprintf(" Mode behavior: %s\n", permissionModeSummary(m.session.Mode))) + b.WriteString(fmt.Sprintf(" Mode behavior: %s\n", permissionModeSummary(m.session.ModeValue()))) if len(allowRules) > 0 { b.WriteString(" Allow: " + strings.Join(allowRules, ", ") + "\n") } @@ -324,7 +324,7 @@ func (m *chatModel) handlePermissionsCommand(parts []string) (chatModel, tea.Cmd m.messages = append(m.messages, displayMsg{role: "system", content: permissionCenterSummary(m)}) case "mode": if len(parts) < 3 { - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Current mode: %s\nBehavior: %s\nUsage: /permissions mode ", permissionModeLabel(m.session), permissionModeSummary(m.session.Mode))}) + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Current mode: %s\nBehavior: %s\nUsage: /permissions mode ", permissionModeLabel(m.session), permissionModeSummary(m.session.ModeValue()))}) return *m, nil } mode, label, ok := normalizePermissionMode(parts[2]) diff --git a/cmd/session_sync.go b/cmd/session_sync.go index fcb47211..b6d2a691 100644 --- a/cmd/session_sync.go +++ b/cmd/session_sync.go @@ -11,7 +11,7 @@ import ( // syncSessionFromPersistedSelection copies eyrie provider.json selection into the // live session when the session fields are empty (status bar can show ActiveModel -// while s.model is still unset, which breaks deployment routing). +// while the model field is still unset, which breaks deployment routing). func syncSessionFromPersistedSelection(sess *engine.Session, settings hawkconfig.Settings) { if sess == nil { return diff --git a/cmd/snapshot_cmd.go b/cmd/snapshot_cmd.go index 29a77c9a..1b09baac 100644 --- a/cmd/snapshot_cmd.go +++ b/cmd/snapshot_cmd.go @@ -76,7 +76,7 @@ func (m chatModel) restoreSnapshot(hash string) (tea.Model, tea.Cmd) { return m, nil } - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Restored to snapshot %s. Files have been reverted.", hash)}) + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Restored snapshot %q — file changes reverted.", hash)}) return m, nil } diff --git a/cmd/statusbar.go b/cmd/statusbar.go index 5dff3533..9b8aa63e 100644 --- a/cmd/statusbar.go +++ b/cmd/statusbar.go @@ -70,9 +70,9 @@ func renderStatusBarRight(m *chatModel) string { focusStyle := lipgloss.NewStyle().Foreground(infoSky).Inline(true) dim := lipgloss.NewStyle().Foreground(dimColor).Inline(true) - tokens := m.session.Cost.PromptTokens + m.session.Cost.CompletionTokens + tokens := m.session.CostValue().PromptTokens + m.session.CostValue().CompletionTokens tokenText := "● " + formatTokenCountCompact(tokens) + " tokens" - costText := fmt.Sprintf("$%.2f", m.session.Cost.Total()) + costText := fmt.Sprintf("$%.2f", m.session.CostValue().Total()) timerText := icons.ClockOutline() + " " + formatSessionDuration(sessionDuration(m)) var meta []string @@ -251,7 +251,7 @@ func statusLineSummary(m *chatModel) string { branch, _ = gitOutput("rev-parse", "--short", "HEAD") } gw, model, _ := m.connectionStatusParts() - tokens := m.session.Cost.PromptTokens + m.session.Cost.CompletionTokens + tokens := m.session.CostValue().PromptTokens + m.session.CostValue().CompletionTokens return fmt.Sprintf( "Status line (footer)\n cwd: %s\n branch: %s\n gateway: %s\n model: %s\n tokens: %s\n cost: $%.2f\n duration: %s\n %s", shortenHomePath(cwd), @@ -259,8 +259,8 @@ func statusLineSummary(m *chatModel) string { gw, model, formatTokenCountWithCommas(tokens), - m.session.Cost.Total(), + m.session.CostValue().Total(), formatSessionDuration(sessionDuration(m)), - m.session.Cost.Summary(), + m.session.CostValue().Summary(), ) } diff --git a/cmd/statusbar_test.go b/cmd/statusbar_test.go index 04887d65..40deb6db 100644 --- a/cmd/statusbar_test.go +++ b/cmd/statusbar_test.go @@ -15,8 +15,8 @@ func TestRenderStatusBar_SignatureExists(t *testing.T) { func TestRenderStatusBarRight_IncludesTokensLabel(t *testing.T) { m := &chatModel{session: &engine.Session{}} - m.session.Cost.PromptTokens = 1200 - m.session.Cost.CompletionTokens = 300 + m.session.CostValue().PromptTokens = 1200 + m.session.CostValue().CompletionTokens = 300 got := renderStatusBarRight(m) if !strings.Contains(got, "tokens") { t.Fatalf("expected tokens label in footer right, got %q", got) diff --git a/internal/engine/session.go b/internal/engine/session.go index be0770a3..c45427e3 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -740,6 +740,36 @@ func (s *Session) SetConvoDAG(dag *storage.DAG) { } } +// SetContextWindowCached sets the catalog context window. New code +// should call this instead of writing to the legacy +// s.ContextWindowCached field directly. +func (s *Session) SetContextWindowCached(n int) { + s.ContextWindowCached = n + if s.persist != nil { + s.persist.SetContextWindowCached(n) + } +} + +// ModeValue returns the active permission mode, with the +// PermissionService's mode taking precedence over the legacy +// s.Mode field. Used by /permissions summary, /status, and the +// chat footer to render the active permission mode. +func (s *Session) ModeValue() PermissionMode { + if s.perms != nil { + return s.perms.Mode() + } + return s.Mode +} + +// SetMode replaces the active permission mode. New code should +// call this instead of writing to the legacy s.Mode field. +func (s *Session) SetMode(mode PermissionMode) { + s.Mode = mode + if s.perms != nil { + _ = s.perms.SetMode(string(mode)) + } +} + // ContextWindowCachedValue returns the cached context window size. // New code should call this instead of reading s.ContextWindowCached // directly. Falls back to the legacy field for back-compat with From a1fbacefe201ea088c3e8c6a624192baa5ef3ffd Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 17 Jun 2026 19:53:42 +0530 Subject: [PATCH 04/14] fix(engine): M2 - AddUser/AddAssistant use MemorySvc() and Persistence().DAG() - AddUser: read Memory from s.MemorySvc().Memory() (was s.Memory) - AddUser, AddUserWithImage, AddAssistant, ForkConversation, SwitchBranch, ListBranches, ConvoHead: read DAG from s.Persistence().DAG() (was s.ConvoDAG) - Aligns with the H6 sub-service decomposition: the legacy ConvoDAG and Memory fields on Session are aliases set by NewSessionWithClient; new code goes through the sub-service getters to keep the god-object-decomposition contract consistent. --- internal/engine/session.go | 46 +++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/internal/engine/session.go b/internal/engine/session.go index c45427e3..c217bd67 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -497,20 +497,20 @@ func (s *Session) SetAPIKeys(apiKeys map[string]string) { func (s *Session) AddUser(content string) { s.Persistence().AddUser(content) - if s.ConvoDAG != nil { + if dag := s.Persistence().DAG(); dag != nil { parentID := "" - if head, err := s.ConvoDAG.Head(context.Background()); err == nil && head != nil { + if head, err := dag.Head(context.Background()); err == nil && head != nil { parentID = head.ID } - _, _ = s.ConvoDAG.Append(context.Background(), parentID, "user", content) + _, _ = dag.Append(context.Background(), parentID, "user", content) } - if s.Memory != nil && strings.Contains(strings.ToLower(content), "remember") { + if mem := s.MemorySvc().Memory(); mem != nil && strings.Contains(strings.ToLower(content), "remember") { go func(c string) { // Use timeout context so goroutine doesn't hang if backend is slow. rCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = rCtx // timeout context available if Remember is extended to accept it - if err := s.Memory.Remember(c, "user_explicit"); err != nil { + if err := mem.Remember(c, "user_explicit"); err != nil { slog.Warn("background memory remember failed", "error", err) } }(content) @@ -528,38 +528,39 @@ func (s *Session) AddUserWithImage(content string, imageBase64 string, imageType } s.messages = append(s.messages, msg) s.mu.Unlock() - if s.ConvoDAG != nil { + if dag := s.Persistence().DAG(); dag != nil { parentID := "" - if head, err := s.ConvoDAG.Head(context.Background()); err == nil && head != nil { + if head, err := dag.Head(context.Background()); err == nil && head != nil { parentID = head.ID } - _, _ = s.ConvoDAG.Append(context.Background(), parentID, "user", content+" [image attached]") + _, _ = dag.Append(context.Background(), parentID, "user", content+" [image attached]") } } func (s *Session) AddAssistant(content string) { s.Persistence().AddAssistant(content) - if s.ConvoDAG != nil { + if dag := s.Persistence().DAG(); dag != nil { parentID := "" - if head, err := s.ConvoDAG.Head(context.Background()); err == nil && head != nil { + if head, err := dag.Head(context.Background()); err == nil && head != nil { parentID = head.ID } - _, _ = s.ConvoDAG.Append(context.Background(), parentID, "assistant", content) + _, _ = dag.Append(context.Background(), parentID, "assistant", content) } } // ForkConversation creates a new branch from a specific point in history. // Returns the fork node ID and the messages up to that point. func (s *Session) ForkConversation(nodeID string) (string, error) { - if s.ConvoDAG == nil { + dag := s.Persistence().DAG() + if dag == nil { return "", nil } - fork, err := s.ConvoDAG.Fork(context.Background(), nodeID) + fork, err := dag.Fork(context.Background(), nodeID) if err != nil { return "", err } // Rebuild messages from the forked branch - history, err := s.ConvoDAG.History(context.Background(), fork.ID) + history, err := dag.History(context.Background(), fork.ID) if err != nil { return "", err } @@ -576,13 +577,14 @@ func (s *Session) ForkConversation(nodeID string) (string, error) { // SwitchBranch navigates to a different branch point and rebuilds messages. func (s *Session) SwitchBranch(nodeID string) error { - if s.ConvoDAG == nil { + dag := s.Persistence().DAG() + if dag == nil { return nil } - if err := s.ConvoDAG.SetHead(context.Background(), nodeID); err != nil { + if err := dag.SetHead(context.Background(), nodeID); err != nil { return err } - history, err := s.ConvoDAG.History(context.Background(), nodeID) + history, err := dag.History(context.Background(), nodeID) if err != nil { return err } @@ -599,18 +601,20 @@ func (s *Session) SwitchBranch(nodeID string) error { // ListBranches returns child nodes (alternative branches) from a given node. func (s *Session) ListBranches(nodeID string) ([]*storage.DAGNode, error) { - if s.ConvoDAG == nil { + dag := s.Persistence().DAG() + if dag == nil { return nil, nil } - return s.ConvoDAG.Branches(context.Background(), nodeID) + return dag.Branches(context.Background(), nodeID) } // ConvoHead returns the current conversation head node ID. func (s *Session) ConvoHead() string { - if s.ConvoDAG == nil { + dag := s.Persistence().DAG() + if dag == nil { return "" } - if head, err := s.ConvoDAG.Head(context.Background()); err == nil && head != nil { + if head, err := dag.Head(context.Background()); err == nil && head != nil { return head.ID } return "" From d952fe77bd095cf9e1cb94f98c0825ab9b64a823 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 17 Jun 2026 19:54:39 +0530 Subject: [PATCH 05/14] fix(engine): M3 - deprecation comments point at real accessors - FewShot() -> FewShotStore() (typo fix in comment) - PlanState, Trajectory, LintLoop, TestLoop, FileMentions, Files, Snapshots, Tracer, RateLimiter: comments now indicate these remain on the legacy Session struct (no sub-service accessor exists); the previous deprecation text pointed to non-existent methods that callers could not find. --- internal/engine/session.go | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/internal/engine/session.go b/internal/engine/session.go index c217bd67..06c8d2db 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -163,33 +163,37 @@ type Session struct { // Advanced features // - // Deprecated: most of these have been folded into sub-services. + // Deprecated: most of these have been folded into sub-services; + // a few remain as legacy fields without a sub-service accessor + // (Trajectory, LintLoop, TestLoop, FileMentions, Files, Snapshots, + // Tracer). For those, keep reading the legacy field — they're + // populated at session construction and don't have a setter. // Autonomy -> s.PermSvc().Autonomy() // Sandbox -> s.Tools().Sandbox() - // Plan -> s.Tools().PlanState() + // Plan -> s.Plan (legacy field; not yet on a sub-service) // Beliefs -> s.LifecycleSvc().Beliefs() // Critic -> s.LifecycleSvc().Critic() // Backtrack -> s.LifecycleSvc().Backtrack() // Limits -> s.LifecycleSvc().Limits() - // Trajectory -> s.LifecycleSvc().Trajectory() + // Trajectory -> legacy field; not yet on a sub-service // Shadow -> s.LifecycleSvc().Shadow() // ConvoDAG -> s.Persistence().DAG() // Sleeptime -> s.MemorySvc().Sleeptime() // Activity -> s.MemorySvc().Activity() // SkillDistiller -> s.MemorySvc().SkillDistiller() - // RateLimiter -> s.ChatLLM().RateLimiter() + // RateLimiter -> s.RateLimiter (legacy field; not yet on ChatLLM) // AgentsAccum -> s.LifecycleSvc().AgentsAccum() - // FewShotStore -> s.LifecycleSvc().FewShot() + // FewShotStore -> s.LifecycleSvc().FewShotStore() // AdaptivePrompt -> s.LifecycleSvc().AdaptivePrompt() - // LintLoop -> s.LifecycleSvc().LintLoop() - // TestLoop -> s.LifecycleSvc().TestLoop() - // FileMentions -> s.MemorySvc().FileMentions() + // LintLoop -> legacy field; not yet on a sub-service + // TestLoop -> legacy field; not yet on a sub-service + // FileMentions -> legacy field; not yet on a sub-service // ResponseCache -> s.LifecycleSvc().ResponseCache() // Pipeline -> s.LifecycleSvc().Pipeline() - // Files -> s.Persistence().Files() + // Files -> legacy field; not yet on Persistence // Steering -> s.Persistence().Steering() - // Snapshots -> s.Persistence().Snapshots() - // Tracer -> global; passed to services at construction. + // Snapshots -> legacy field; not yet on Persistence + // Tracer -> legacy field; oteltrace.NewTracer() for new code Autonomy AutonomyLevel // autonomy.go — permission level Sandbox *DiffSandbox // diffsandbox.go — staged file changes Plan *PlanState // subtask.go — user-activated plan From 3da81c47f98d3461ea6347b6350241ce84044851 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 17 Jun 2026 19:56:44 +0530 Subject: [PATCH 06/14] fix(engine): M4 - PermissionService constructor no longer pre-sets mode NewPermissionService no longer initializes mode to PermissionModeDefault; the zero value ("") is preserved so IsZero() can correctly report a freshly constructed service. IsZero() now checks for the empty string instead of PermissionModeDefault. This is the right semantics: callers that want a default mode should call SetMode explicitly, and tests can rely on IsZero() being true for a fresh service. --- internal/engine/permission_service.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index 761a8821..53fb71db 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -51,6 +51,11 @@ type PermissionService struct { // NewPermissionService constructs a PermissionService with a fresh // PermissionEngine. Tests can inject a custom engine via WithEngine. +// +// Note: mode is intentionally left at the zero value (PermissionMode("")) +// so that IsZero() correctly reports true for a freshly constructed +// service. Callers that want the default mode should call SetMode +// (or set the field directly during tests). See M4 in the code review. func NewPermissionService(log *logger.Logger) *PermissionService { if log == nil { log = logger.Default() @@ -62,7 +67,6 @@ func NewPermissionService(log *logger.Logger) *PermissionService { autoMode: pe.AutoMode, classifier: pe.Classifier, bypassKill: pe.BypassKill, - mode: PermissionModeDefault, log: log, } } @@ -185,8 +189,9 @@ func (s *PermissionService) BypassKill() *permissions.BypassKillswitch { return // IsZero reports whether this service has been fully configured. // A zero PermissionService has no approval gate, no custom permission -// fn, and the default mode — that's the "freshly constructed" state -// used by NewSessionWithClient. +// fn, and an empty mode — that's the "freshly constructed" state +// used by NewSessionWithClient (the constructor no longer pre-sets +// mode = PermissionModeDefault, see M4 in the code review). func (s *PermissionService) IsZero() bool { - return s == nil || (s.approval == nil && s.permissionFn == nil && s.mode == PermissionModeDefault) + return s == nil || (s.approval == nil && s.permissionFn == nil && s.mode == "") } From 31d89284ea7d71888add12b320f352d6e16df290 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 17 Jun 2026 19:57:47 +0530 Subject: [PATCH 07/14] fix(cmd): M5 - SubcommandRegistry.Register detects alias collision The previous implementation checked for primary-name duplicates (`r.primary[name]`) but not alias collisions. Two subcommands that each register a different primary but share an alias would silently overwrite `r.aliasOf[alias]`. Now the alias check mirrors the primary-name check: registration is rejected if any of the subcommand's aliases is already taken. The existing silent-no-op pattern is preserved (no error return) so init() callers don't have to change. --- cmd/chat_subcommand.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/chat_subcommand.go b/cmd/chat_subcommand.go index f2189961..c90f6a47 100644 --- a/cmd/chat_subcommand.go +++ b/cmd/chat_subcommand.go @@ -89,7 +89,10 @@ func NewSubcommandRegistry() *SubcommandRegistry { // all aliases are indexed. If a name is already registered, this // is a no-op (the existing entry is kept) — duplicate registration // is treated as a configuration error but doesn't panic, so test -// ordering and re-init don't blow up the binary. +// ordering and re-init don't blow up the binary. The same applies +// to alias collisions: if any of the subcommand's aliases is already +// registered (either as a primary or as another alias), registration +// is rejected (see M5 in the code review). func (r *SubcommandRegistry) Register(cmd ChatSubcommand) { if cmd == nil { return @@ -100,6 +103,11 @@ func (r *SubcommandRegistry) Register(cmd ChatSubcommand) { if _, exists := r.primary[name]; exists { return // duplicate } + for _, alias := range cmd.Aliases() { + if _, exists := r.aliasOf[alias]; exists { + return // alias collision + } + } r.primary[name] = cmd for _, alias := range cmd.Aliases() { r.aliasOf[alias] = name From 2a4faa9cacdfe63ddd2d82ee23255860c45d37ff Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 17 Jun 2026 20:00:20 +0530 Subject: [PATCH 08/14] fix(cmd): M6 - sessionSubcommand.Handle prepends command name to args The previous Handle passed the post-name args slice directly to handleSessionCommand, which expects parts[0] to be the command name. This broke /recover , /resume , and /tag